using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using ApiDenuncias.Configuration;
using GestionaDenuncias.Shared.Models;
using Konscious.Security.Cryptography;
using Microsoft.Extensions.Options;

namespace ApiDenuncias.Services;

public sealed record PreparedGlobalLeaksCredentials(string Username, string FinalPassword, string TokenAnswer);

public sealed record RefreshedGlobalLeaksSession(
    GlobalLeaksProofOfWorkToken ProofOfWorkToken,
    DateTimeOffset? SessionExpiresAtUtc);

public sealed class GlobalLeaksClient
{
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
    private const int ActivityAnalysisMaxParallelism = 3;
    private const string AuthenticationPath = "/api/auth/authentication";
    private readonly HttpClient _httpClient;
    private readonly ILogger<GlobalLeaksClient> _logger;
    private readonly GlobalLeaksOptions _options;

    public GlobalLeaksClient(IOptions<GlobalLeaksOptions> options, ILogger<GlobalLeaksClient> logger)
    {
        _options = options.Value;
        _logger = logger;

        var handler = new HttpClientHandler();
        if (_options.AllowInvalidCertificate)
        {
            _logger.LogWarning("GlobalLeaks permite certificados TLS no validos. Usar solo temporalmente en PRE.");
            handler.ServerCertificateCustomValidationCallback =
                HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
        }

        _httpClient = new HttpClient(handler)
        {
            BaseAddress = new Uri(_options.BaseUrl.TrimEnd('/')),
            Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds),
        };

        if (!string.IsNullOrWhiteSpace(_options.HostHeader))
        {
            _httpClient.DefaultRequestHeaders.Host = _options.HostHeader.Trim();
        }
    }

    public async Task<GlSession> LoginAsync(
        string username,
        string password,
        string authcode,
        CancellationToken cancellationToken)
    {
        var prepared = await PrepareLoginAsync(username, password, cancellationToken);
        return await CompleteLoginAsync(
            prepared.Username,
            prepared.FinalPassword,
            prepared.TokenAnswer,
            authcode,
            cancellationToken);
    }

    public async Task<PreparedGlobalLeaksCredentials> PrepareLoginAsync(
        string username,
        string password,
        CancellationToken cancellationToken)
    {
        var loginWatch = Stopwatch.StartNew();
        _logger.LogInformation(
            "GlobalLeaks login: iniciando para {Username}. BaseUrl={BaseUrl}",
            username,
            _options.BaseUrl);


        using var typeRequest = CreateRequest(HttpMethod.Post, "/api/auth/type");
        typeRequest.Content = CreateJsonContent(new { username });
        using var typeResponse = await SendLoginRequestAsync(typeRequest, "/api/auth/type", cancellationToken);
        await EnsureSuccessOrThrowAsync(typeResponse, "/api/auth/type", cancellationToken);

        var authType = await typeResponse.Content.ReadFromJsonAsync<AuthTypeResponse>(JsonOptions, cancellationToken)
            ?? throw new GlobalLeaksValidationException("No se pudo obtener el tipo de autenticación.", 502);

        var passwordWatch = Stopwatch.StartNew();
        _logger.LogInformation(
            "GlobalLeaks login: preparando credenciales para {Username}. AuthType={AuthType}",
            username,
            authType.Type);
        var finalPassword = authType.Type == "key"
            ? DerivePassword(password, authType.Salt)
            : password;
        _logger.LogInformation(
            "GlobalLeaks login: credenciales preparadas para {Username} en {ElapsedMs} ms",
            username,
            passwordWatch.ElapsedMilliseconds);

        var tokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);

        _logger.LogInformation(
            "GlobalLeaks login: credenciales y proof-of-work preparados para {Username}. Tiempo total={ElapsedMs} ms",
            username,
            loginWatch.ElapsedMilliseconds);

        return new PreparedGlobalLeaksCredentials(username, finalPassword, tokenAnswer);
    }

    public async Task<GlSession> CompleteLoginAsync(
        string username,
        string finalPassword,
        string authcode,
        CancellationToken cancellationToken)
    {
        var tokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);
        return await CompleteLoginAsync(username, finalPassword, tokenAnswer, authcode, cancellationToken);
    }

    public async Task<GlSession> CompleteLoginAsync(
        string username,
        string finalPassword,
        string tokenAnswer,
        string authcode,
        CancellationToken cancellationToken)
    {
        var loginWatch = Stopwatch.StartNew();
        _logger.LogInformation(
            "GlobalLeaks login: enviando autenticacion final para {Username}. AuthcodeLength={AuthcodeLength}",
            username,
            authcode?.Length ?? 0);

        var currentTokenAnswer = tokenAnswer;
        using var dpopKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        for (var attempt = 0; attempt < 2; attempt++)
        {
            using var authRequest = CreateRequest(
                HttpMethod.Post,
                AuthenticationPath);
            authRequest.Content = CreateJsonContent(new
            {
                tid = 1,
                username,
                password = finalPassword,
                authcode = authcode?.Trim() ?? string.Empty,
            });
            authRequest.Headers.TryAddWithoutValidation("X-Token", currentTokenAnswer);
            authRequest.Headers.TryAddWithoutValidation("DPoP", CreateDpopProof(dpopKey, "POST", AuthenticationPath));

            using var authResponse = await SendLoginRequestAsync(authRequest, AuthenticationPath, cancellationToken);
            if (authResponse.IsSuccessStatusCode)
            {
                var authBody = await authResponse.Content.ReadAsStringAsync(cancellationToken);
                var session = ParseAuthSession(authBody, username, ExportDpopPrivateKey(dpopKey));
                _logger.LogInformation(
                    "Login GlobalLeaks correcto para {Username}. Rol: {Role}. Tiempo total={ElapsedMs} ms",
                    session.Username,
                    session.Role ?? "(sin rol)",
                    loginWatch.ElapsedMilliseconds);
                return session;
            }

            var body = await ReadBodySafeAsync(authResponse, cancellationToken);
            if (attempt == 0 && IsMissingTokenOrSessionError(body))
            {
                _logger.LogWarning(
                    "GlobalLeaks rechazo el token preparado para {Username}; se generara un token nuevo y se reintentara la autenticacion.",
                    username);
                currentTokenAnswer = await PrepareProofOfWorkAsync(username, cancellationToken);
                continue;
            }

            throw authResponse.StatusCode switch
            {
                HttpStatusCode.Unauthorized when IsInvalidDpopProof(body) => new GlobalLeaksValidationException(
                    "GlobalLeaks ha rechazado la prueba DPoP del login. Revisa la version de GlobalLeaks y el formato del proof.",
                    StatusCodes.Status401Unauthorized),
                HttpStatusCode.Unauthorized => new GlobalLeaksValidationException(
                    "Credenciales incorrectas o codigo 2FA invalido.",
                    StatusCodes.Status401Unauthorized),
                (HttpStatusCode)429 => new GlobalLeaksValidationException(
                    "Demasiados intentos en GlobalLeaks. Espera unos minutos.",
                    StatusCodes.Status429TooManyRequests),
                HttpStatusCode.NotAcceptable when IsTwoFactorRequiredError(body) =>
                    new GlobalLeaksValidationException(
                        "Codigo 2FA invalido, caducado o ya utilizado. Introduce el codigo actual de la app autenticadora.",
                        StatusCodes.Status406NotAcceptable),
                _ => new GlobalLeaksValidationException(
                    string.IsNullOrWhiteSpace(body)
                        ? $"Login fallido (codigo {(int)authResponse.StatusCode})."
                        : $"Login fallido (codigo {(int)authResponse.StatusCode}): {body}",
                    (int)authResponse.StatusCode),
            };
        }

        throw new GlobalLeaksValidationException("Login fallido: no se pudo completar la autenticacion.", 502);
    }

    public async Task<RefreshedGlobalLeaksSession> RefreshSessionAsync(
        string sessionId,
        string dpopPrivateKey,
        GlobalLeaksProofOfWorkToken proofOfWorkToken,
        CancellationToken cancellationToken = default)
    {
        const string path = "/api/auth/session";
        var tokenAnswer = SolveProofOfWork(
            proofOfWorkToken.Id,
            proofOfWorkToken.Salt,
            cancellationToken);

        using var request = CreateAuthenticatedRequest(
            HttpMethod.Post,
            path,
            sessionId,
            dpopPrivateKey);
        request.Content = CreateJsonContent(new { token = tokenAnswer });

        using var response = await SendGlRequestAsync(request, cancellationToken);
        var body = await response.Content.ReadAsStringAsync(cancellationToken);
        return ParseSessionRefresh(body);
    }

    private async Task<string> PrepareProofOfWorkAsync(string username, CancellationToken cancellationToken)
    {
        using var tokenRequest = CreateRequest(HttpMethod.Post, "/api/auth/token");
        using var tokenResponse = await SendLoginRequestAsync(tokenRequest, "/api/auth/token", cancellationToken);
        await EnsureSuccessOrThrowAsync(tokenResponse, "/api/auth/token", cancellationToken);
        var tokenData = await tokenResponse.Content.ReadFromJsonAsync<TokenResponse>(JsonOptions, cancellationToken)
            ?? throw new GlobalLeaksValidationException("No se pudo obtener el reto criptografico.", 502);

        var proofWatch = Stopwatch.StartNew();
        _logger.LogInformation("GlobalLeaks login: resolviendo proof-of-work para {Username}", username);
        var tokenAnswer = SolveProofOfWork(tokenData.Id, tokenData.Salt, cancellationToken);
        _logger.LogInformation(
            "GlobalLeaks login: proof-of-work resuelto para {Username} en {ElapsedMs} ms",
            username,
            proofWatch.ElapsedMilliseconds);
        return tokenAnswer;
    }

    public async Task<IReadOnlyList<ContextDto>> GetContextsAsync(
        string sessionId,
        CancellationToken cancellationToken)
    {
        using var request = CreateRequest(HttpMethod.Get, "/api/public");
        using var response = await SendGlRequestAsync(request, cancellationToken);
        var body = await response.Content.ReadAsStringAsync(cancellationToken);
        var contexts = ParseContexts(body);
        _logger.LogInformation("GlobalLeaks /api/public devolvió {Count} contextos", contexts.Count);
        return contexts;
    }

    public async Task<IReadOnlyList<ReportDto>> GetReportsAsync(
        string sessionId,
        string? filter,
        string? dateFrom,
        string? dateTo,
        CancellationToken cancellationToken,
        IReadOnlyList<ContextDto>? contexts = null,
        string? dpopPrivateKey = null)
    {
        filter ??= "all";

        using var reportsResponse = await SendAuthenticatedGlRequestAsync(
            HttpMethod.Get,
            "/api/recipient/rtips",
            sessionId,
            dpopPrivateKey,
            cancellationToken);
        var body = await reportsResponse.Content.ReadAsStringAsync(cancellationToken);
        var tips = ParseReports(body);
        _logger.LogInformation("GlobalLeaks /api/recipient/rtips devolvió {Count} denuncias", tips.Count);

        var availableContexts = contexts ?? await GetContextsAsync(sessionId, cancellationToken);
        var contextNames = availableContexts.ToDictionary(c => c.Id, c => c.Name, StringComparer.OrdinalIgnoreCase);

        IEnumerable<RawReport> filtered = tips;

        filtered = filter switch
        {
            "new" => filtered.Where(t => string.IsNullOrWhiteSpace(t.AccessDate) || t.Status == "new"),
            "updated" or "updated_citizen" or "updated_receiver" => filtered.Where(t => t.Updated),
            _ => filtered,
        };

        if (!string.IsNullOrWhiteSpace(dateFrom))
        {
            if (!DateOnly.TryParseExact(dateFrom, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var from))
            {
                throw new GlobalLeaksValidationException("Formato de fecha inválido para date_from (use YYYY-MM-DD)");
            }

            filtered = filtered.Where(t =>
            {
                var date = ParseDate(t.CreationDate);
                return date is not null && DateOnly.FromDateTime(date.Value.DateTime) >= from;
            });
        }

        if (!string.IsNullOrWhiteSpace(dateTo))
        {
            if (!DateOnly.TryParseExact(dateTo, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var to))
            {
                throw new GlobalLeaksValidationException("Formato de fecha inválido para date_to (use YYYY-MM-DD)");
            }

            filtered = filtered.Where(t =>
            {
                var date = ParseDate(t.CreationDate);
                return date is not null && DateOnly.FromDateTime(date.Value.DateTime) <= to;
            });
        }

        return filtered
            .OrderByDescending(t => t.Progressive ?? 0)
            .Select(t => new ReportDto
            {
                Id = t.Id,
                Progressive = t.Progressive,
                ContextId = t.ContextId,
                ContextName = t.ContextId is not null && contextNames.TryGetValue(t.ContextId, out var name)
                    ? name
                    : t.ContextId,
                CreationDate = t.CreationDate,
                UpdateDate = t.UpdateDate,
                ExpirationDate = t.ExpirationDate,
                ReminderDate = t.ReminderDate,
                AccessDate = t.AccessDate,
                LastAccess = t.LastAccess,
                WhistleblowerLastAccess = t.WhistleblowerLastAccess,
                Status = t.Status,
                Updated = t.Updated,
                Accessible = t.Accessible,
                Label = t.Label,
            })
            .ToArray();
    }

    public async Task<IReadOnlyList<ReportDto>> EnrichReportsWithActivityAsync(
        string sessionId,
        IReadOnlyList<ReportDto> reports,
        DateTimeOffset? fallbackReference,
        CancellationToken cancellationToken,
        string? dpopPrivateKey = null)
    {
        if (reports.Count == 0)
        {
            return reports;
        }

        var enriched = reports.ToArray();
        var candidates = reports
            .Select((report, index) => (Report: report, Index: index))
            .Where(item => ShouldAnalyzeCitizenActivity(item.Report))
            .ToList();

        if (candidates.Count == 0)
        {
            return enriched;
        }

        using var gate = new SemaphoreSlim(ActivityAnalysisMaxParallelism, ActivityAnalysisMaxParallelism);
        var tasks = candidates.Select(async item =>
        {
            await gate.WaitAsync(cancellationToken);
            try
            {
                var reference = GetActivityReference(item.Report, fallbackReference);
                var activity = await AnalyzeReportActivityAsync(sessionId, item.Report, reference, dpopPrivateKey, cancellationToken);
                enriched[item.Index] = ApplyActivity(item.Report, activity);
            }
            catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
            {
                _logger.LogWarning(
                    ex,
                    "No se ha podido analizar la actividad ciudadano/OAAF de la denuncia {Progressive} ({ReportId}).",
                    item.Report.Progressive,
                    item.Report.Id);
            }
            finally
            {
                gate.Release();
            }
        });

        await Task.WhenAll(tasks);
        return enriched;
    }

    public async Task<ReportDetailDto> GetReportDetailAsync(
        string sessionId,
        string reportId,
        string? lastAccess,
        CancellationToken cancellationToken,
        string? dpopPrivateKey = null)
    {
        ValidateUuid(reportId);

        var reference = ParseDate(lastAccess);
        using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, dpopPrivateKey, cancellationToken);
        IReadOnlyList<ReportCommentDto>? comments = null;
        try
        {
            comments = await GetReportCommentsAsync(sessionId, reportId, reference, defaultNewWhenNoReference: true, dpopPrivateKey, cancellationToken);
        }
        catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
        {
            _logger.LogWarning(
                ex,
                "No se han podido leer los comentarios dedicados de la denuncia {ReportId}. Se intentara usar el detalle del tip.",
                reportId);
        }

        return ParseReportDetail(reportId, lastAccess, document.RootElement, comments);
    }

    public async Task<FileDownloadResult> DownloadReportPackageAsync(
        string sessionId,
        string reportId,
        CancellationToken cancellationToken,
        string? dpopPrivateKey = null)
    {
        ValidateUuid(reportId);

        using var response = await SendAuthenticatedGlRequestAsync(
            HttpMethod.Get,
            $"/api/recipient/rtips/{reportId}/export",
            sessionId,
            dpopPrivateKey,
            cancellationToken,
            HttpCompletionOption.ResponseHeadersRead);

        var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
        if (content.Length > _options.MaxDownloadBytes)
        {
            throw new GlobalLeaksValidationException("El fichero supera el límite de 500 MB.", 413);
        }

        var fileName = SanitizeFileName(
            ExtractFileName(response.Content.Headers.ContentDisposition),
            $"report-{reportId}");

        return new FileDownloadResult(content, fileName);
    }

    public async Task<FileDownloadResult> ExportReportJsonAsync(
        string sessionId,
        string reportId,
        CancellationToken cancellationToken,
        string? dpopPrivateKey = null)
    {
        ValidateUuid(reportId);

        using var response = await SendAuthenticatedGlRequestAsync(
            HttpMethod.Get,
            $"/api/recipient/rtips/{reportId}",
            sessionId,
            dpopPrivateKey,
            cancellationToken);

        var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
        var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
        if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
        {
            throw new GlobalLeaksValidationException(
                "GlobalLeaks no devolvió JSON (tip sin clave de descifrado).",
                422);
        }

        using var document = JsonDocument.Parse(content);
        var progressive = document.RootElement.TryGetProperty("progressive", out var value) && value.TryGetInt32(out var number)
            ? number.ToString(CultureInfo.InvariantCulture)
            : reportId[..8];

        return new FileDownloadResult(content, $"report-{progressive}.json");
    }

    private async Task<JsonDocument> ReadReportDetailDocumentAsync(
        string sessionId,
        string reportId,
        string? dpopPrivateKey,
        CancellationToken cancellationToken)
    {
        using var detailResponse = await SendAuthenticatedGlRequestAsync(
            HttpMethod.Get,
            $"/api/recipient/rtips/{reportId}",
            sessionId,
            dpopPrivateKey,
            cancellationToken);

        var contentType = detailResponse.Content.Headers.ContentType?.MediaType ?? string.Empty;
        var content = await detailResponse.Content.ReadAsByteArrayAsync(cancellationToken);
        if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
        {
            throw new GlobalLeaksValidationException(
                "No se pudo leer el detalle de la denuncia. Puede estar cifrada sin clave disponible en el servidor.",
                422);
        }

        return JsonDocument.Parse(content);
    }

    private async Task<IReadOnlyList<ReportCommentDto>> GetReportCommentsAsync(
        string sessionId,
        string reportId,
        DateTimeOffset? reference,
        bool defaultNewWhenNoReference,
        string? dpopPrivateKey,
        CancellationToken cancellationToken)
    {
        using var response = await SendAuthenticatedGlRequestAsync(
            HttpMethod.Get,
            $"/api/recipient/rtips/{reportId}/comments",
            sessionId,
            dpopPrivateKey,
            cancellationToken);

        var contentType = response.Content.Headers.ContentType?.MediaType ?? string.Empty;
        var content = await response.Content.ReadAsByteArrayAsync(cancellationToken);
        if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || content.Length == 0)
        {
            return [];
        }

        using var document = JsonDocument.Parse(content);
        var root = document.RootElement;
        var comments = root.ValueKind == JsonValueKind.Array
            ? root
            : FindArrayProperty(root, "comments", "items", "data", "entries", "results");

        if (comments is null || comments.Value.ValueKind != JsonValueKind.Array)
        {
            return [];
        }

        return comments.Value
            .EnumerateArray()
            .Select(item => CreateReportComment(item, reference, defaultNewWhenNoReference))
            .ToArray();
    }

    private async Task<ReportActivitySnapshot> AnalyzeReportActivityAsync(
        string sessionId,
        ReportDto report,
        DateTimeOffset? reference,
        string? dpopPrivateKey,
        CancellationToken cancellationToken)
    {
        var reportId = report.Id;
        using var document = await ReadReportDetailDocumentAsync(sessionId, reportId, dpopPrivateKey, cancellationToken);
        IReadOnlyList<ReportCommentDto> comments;
        try
        {
            comments = await GetReportCommentsAsync(
                sessionId,
                reportId,
                reference,
                defaultNewWhenNoReference: false,
                dpopPrivateKey,
                cancellationToken);
        }
        catch (Exception ex) when (ex is GlobalLeaksValidationException or HttpRequestException or TaskCanceledException)
        {
            _logger.LogWarning(
                ex,
                "No se han podido leer los comentarios dedicados para analizar actividad de la denuncia {ReportId}. Se usara el detalle del tip si contiene comentarios.",
                reportId);
            comments = EnumerateArray(document.RootElement, "comments")
                .Select(item => CreateReportComment(item, reference, defaultNewWhenNoReference: false))
                .ToArray();
        }

        var whistleblowerFiles = ParseReportFiles(
            document.RootElement,
            reference,
            defaultNewWhenNoReference: false,
            "wbfiles",
            "files");
        var receiverFiles = ParseReportFiles(
            document.RootElement,
            reference,
            defaultNewWhenNoReference: false,
            "rfiles");
        var receivers = ParseReportReceivers(document.RootElement);
        var receiverNames = BuildReceiverNameLookup(receivers);
        comments = comments
            .Select(comment => WithAuthorName(comment, receiverNames))
            .ToArray();
        receiverFiles = receiverFiles
            .Select(file => WithAuthorName(file, receiverNames))
            .ToArray();

        var citizenCommentDates = comments
            .Where(comment => IsWhistleblowerActivityType(comment.Type))
            .Select(comment => ParseDate(comment.CreationDate))
            .Where(date => date is not null)
            .Select(date => date!.Value);
        var receiverCommentDates = comments
            .Where(comment => IsReceiverActivityType(comment.Type))
            .Select(comment => ParseDate(comment.CreationDate))
            .Where(date => date is not null)
            .Select(date => date!.Value);
        var unclassifiedCommentDates = comments
            .Where(comment => IsUnclassifiedActivityType(comment.Type))
            .Select(comment => ParseDate(comment.CreationDate))
            .Where(date => date is not null)
            .Select(date => date!.Value);
        var citizenFileDates = whistleblowerFiles
            .Select(file => ParseDate(file.CreationDate))
            .Where(date => date is not null)
            .Select(date => date!.Value);
        var receiverFileDates = receiverFiles
            .Select(file => ParseDate(file.CreationDate))
            .Where(date => date is not null)
            .Select(date => date!.Value);

        var reportCreationDate = ParseDate(report.CreationDate) ??
                                 ParseDate(GetString(document.RootElement, "creation_date", "creationDate"));
        var citizenActivityDates = reportCreationDate is null
            ? citizenCommentDates.Concat(citizenFileDates)
            : citizenCommentDates.Concat(citizenFileDates).Append(reportCreationDate.Value);

        var citizenLast = MaxDate(citizenActivityDates);
        var receiverEvents = comments
            .Where(comment =>
                IsReceiverActivityType(comment.Type) ||
                IsUnclassifiedActivityType(comment.Type))
            .Select(comment => new ActivityActorEvent(
                ParseDate(comment.CreationDate),
                comment.AuthorId,
                comment.AuthorName))
            .Concat(receiverFiles.Select(file => new ActivityActorEvent(
                ParseDate(file.CreationDate),
                file.AuthorId,
                file.AuthorName)))
            .Where(item => item.Date is not null)
            .OrderByDescending(item => item.Date)
            .ToArray();
        var latestReceiverEvent = receiverEvents.FirstOrDefault();
        var receiverLast = latestReceiverEvent?.Date ??
                           MaxDate(receiverCommentDates.Concat(receiverFileDates).Concat(unclassifiedCommentDates));

        return new ReportActivitySnapshot(
            citizenLast,
            comments.Any(comment =>
                IsWhistleblowerActivityType(comment.Type) &&
                comment.IsNew),
            whistleblowerFiles.Any(file => file.IsNew),
            receiverLast,
            comments.Any(comment =>
                IsReceiverActivityType(comment.Type) &&
                comment.IsNew) ||
            comments.Any(comment =>
                IsUnclassifiedActivityType(comment.Type) &&
                comment.IsNew) ||
            receiverFiles.Any(file => file.IsNew),
            latestReceiverEvent?.AuthorId,
            latestReceiverEvent?.AuthorName);
    }

    private static ReportDto ApplyActivity(ReportDto report, ReportActivitySnapshot activity)
    {
        return report with
        {
            ActivityAnalyzed = true,
            CitizenLastActivity = activity.CitizenLastActivity?.ToString("O", CultureInfo.InvariantCulture),
            CitizenHasNewActivity = activity.HasNewCitizenComment || activity.HasNewCitizenFile,
            CitizenHasNewComment = activity.HasNewCitizenComment,
            CitizenHasNewFile = activity.HasNewCitizenFile,
            ReceiverLastActivity = activity.ReceiverLastActivity?.ToString("O", CultureInfo.InvariantCulture),
            ReceiverHasNewActivity = activity.HasNewReceiverActivity,
            ReceiverLastActivityAuthorId = activity.ReceiverLastActivityAuthorId,
            ReceiverLastActivityAuthorName = activity.ReceiverLastActivityAuthorName
        };
    }

    private async Task<HttpResponseMessage> SendLoginRequestAsync(
        HttpRequestMessage request,
        string endpoint,
        CancellationToken cancellationToken)
    {
        var stepWatch = Stopwatch.StartNew();
        _logger.LogInformation("GlobalLeaks login: llamando a {Endpoint}", endpoint);

        try
        {
            var response = await _httpClient.SendAsync(request, cancellationToken);
            _logger.LogInformation(
                "GlobalLeaks login: {Endpoint} respondio {StatusCode} en {ElapsedMs} ms",
                endpoint,
                (int)response.StatusCode,
                stepWatch.ElapsedMilliseconds);
            return response;
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            _logger.LogWarning(
                "GlobalLeaks login: timeout en {Endpoint} tras {ElapsedMs} ms",
                endpoint,
                stepWatch.ElapsedMilliseconds);
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex,
                "GlobalLeaks login: error en {Endpoint} tras {ElapsedMs} ms",
                endpoint,
                stepWatch.ElapsedMilliseconds);
            throw;
        }
    }

    private async Task<HttpResponseMessage> SendGlRequestAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken,
        HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
    {
        var response = await _httpClient.SendAsync(request, completionOption, cancellationToken);
        if ((int)response.StatusCode == 412)
        {
            _logger.LogWarning(
                "GlobalLeaks ha indicado sesion expirada (412) en {Method} {Path}.",
                request.Method.Method,
                request.RequestUri?.OriginalString);
            response.Dispose();
            throw new GlobalLeaksSessionExpiredException();
        }

        if (!response.IsSuccessStatusCode)
        {
            var body = await ReadBodySafeAsync(response, cancellationToken);
            _logger.LogWarning(
                "GlobalLeaks devolvio {StatusCode} en {Method} {Path}. Body={Body}",
                (int)response.StatusCode,
                request.Method.Method,
                request.RequestUri?.OriginalString,
                string.IsNullOrWhiteSpace(body) ? "(sin cuerpo)" : body);
            var message = string.IsNullOrWhiteSpace(body)
                ? $"Error de GlobalLeaks (código {(int)response.StatusCode})."
                : $"Error de GlobalLeaks (código {(int)response.StatusCode}): {body}";
            response.Dispose();
            throw new GlobalLeaksValidationException(message, (int)response.StatusCode);
        }

        return response;
    }

    private async Task<HttpResponseMessage> SendAuthenticatedGlRequestAsync(
        HttpMethod method,
        string path,
        string sessionId,
        string? dpopPrivateKey,
        CancellationToken cancellationToken,
        HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
    {
        if (string.IsNullOrWhiteSpace(dpopPrivateKey))
        {
            using var requestWithoutDpop = CreateAuthenticatedRequest(method, path, sessionId, dpopPrivateKey: null);
            return await SendGlRequestAsync(requestWithoutDpop, cancellationToken, completionOption);
        }

        using var requestWithDpop = CreateAuthenticatedRequest(method, path, sessionId, dpopPrivateKey);
        return await SendGlRequestAsync(requestWithDpop, cancellationToken, completionOption);
    }

    private static HttpRequestMessage CreateRequest(HttpMethod method, string path)
    {
        var request = new HttpRequestMessage(method, path)
        {
            Version = HttpVersion.Version11,
            VersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
        };

        request.Headers.TryAddWithoutValidation("Accept", "*/*");
        request.Headers.TryAddWithoutValidation("User-Agent", "python-requests/2.32.5");
        return request;
    }

    private static HttpRequestMessage CreateAuthenticatedRequest(
        HttpMethod method,
        string path,
        string sessionId,
        string? dpopPrivateKey)
    {
        var request = CreateRequest(method, path);
        request.Headers.Add("X-Session", sessionId);
        if (!string.IsNullOrWhiteSpace(dpopPrivateKey))
        {
            using var key = ImportDpopPrivateKey(dpopPrivateKey);
            request.Headers.TryAddWithoutValidation(
                "DPoP",
                CreateDpopProof(
                    key,
                    method.Method.ToUpperInvariant(),
                    GetDpopPath(path),
                    ComputeAth(sessionId)));
        }

        return request;
    }

    private static ByteArrayContent CreateJsonContent<T>(T payload)
    {
        var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions);
        var content = new ByteArrayContent(bytes);
        content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
        return content;
    }

    private static string SolveProofOfWork(string tokenId, string tokenSalt, CancellationToken cancellationToken)
    {
        var idBytes = Encoding.UTF8.GetBytes(tokenId);
        var saltBytes = Convert.FromBase64String(tokenSalt).Take(16).ToArray();
        var n = 0;

        while (true)
        {
            if ((n & 15) == 0)
            {
                cancellationToken.ThrowIfCancellationRequested();
            }

            var input = idBytes.Concat(Encoding.UTF8.GetBytes(n.ToString(CultureInfo.InvariantCulture))).ToArray();
            var hash = ComputeArgon2(input, saltBytes, iterations: 1, memoryKb: 1024);
            if (hash[^1] == 0)
            {
                return $"{tokenId}:{n}";
            }

            n++;
        }
    }

    private static string DerivePassword(string password, string salt)
    {
        var saltBytes = Convert.FromBase64String(salt).Take(16).ToArray();
        var hash = ComputeArgon2(Encoding.UTF8.GetBytes(password), saltBytes, iterations: 16, memoryKb: 131072);
        return Convert.ToBase64String(hash);
    }

    private static string CreateDpopProof(ECDsa privateKey, string method, string path, string? ath = null)
    {
        var publicParameters = privateKey.ExportParameters(includePrivateParameters: false);
        var jwk = new Dictionary<string, object?>
        {
            ["kty"] = "EC",
            ["crv"] = "P-256",
            ["x"] = Base64UrlEncode(publicParameters.Q.X ?? []),
            ["y"] = Base64UrlEncode(publicParameters.Q.Y ?? []),
        };

        var header = new Dictionary<string, object?>
        {
            ["typ"] = "dpop+jwt",
            ["alg"] = "ES256",
            ["jwk"] = jwk,
        };

        var payload = new Dictionary<string, object?>
        {
            ["jti"] = Guid.NewGuid().ToString(),
            ["htm"] = method,
            ["htu"] = path,
            ["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
        };
        if (!string.IsNullOrWhiteSpace(ath))
        {
            payload["ath"] = ath;
        }

        var encodedHeader = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(header, JsonOptions));
        var encodedPayload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions));
        var signingInput = $"{encodedHeader}.{encodedPayload}";
        var signature = privateKey.SignData(
            Encoding.ASCII.GetBytes(signingInput),
            HashAlgorithmName.SHA256,
            DSASignatureFormat.IeeeP1363FixedFieldConcatenation);

        return $"{signingInput}.{Base64UrlEncode(signature)}";
    }

    private static string ExportDpopPrivateKey(ECDsa key)
        => Convert.ToBase64String(key.ExportECPrivateKey());

    private static ECDsa ImportDpopPrivateKey(string privateKey)
    {
        var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        key.ImportECPrivateKey(Convert.FromBase64String(privateKey), out _);
        return key;
    }

    private static string ComputeAth(string sessionId)
        => Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes(sessionId)));

    private static string GetDpopPath(string path)
    {
        var queryIndex = path.IndexOf('?', StringComparison.Ordinal);
        return queryIndex < 0 ? path : path[..queryIndex];
    }

    private static string Base64UrlEncode(byte[] bytes)
        => Convert.ToBase64String(bytes)
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');

    private static byte[] ComputeArgon2(byte[] input, byte[] salt, int iterations, int memoryKb)
    {
        var argon2 = new Argon2id(input)
        {
            Salt = salt,
            DegreeOfParallelism = 1,
            Iterations = iterations,
            MemorySize = memoryKb,
        };

        return argon2.GetBytes(32);
    }

    private static void ValidateUuid(string value)
    {
        if (!Guid.TryParse(value, out _))
        {
            throw new GlobalLeaksValidationException("ID de denuncia inválido");
        }
    }

    private static DateTimeOffset? ParseDate(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return null;
        }

        return DateTimeOffset.TryParse(value.Replace("Z", "+00:00", StringComparison.Ordinal), out var parsed)
            ? parsed
            : null;
    }

    private static bool ShouldAnalyzeCitizenActivity(ReportDto report)
    {
        return report.Accessible != false && !string.IsNullOrWhiteSpace(report.Id);
    }

    private static DateTimeOffset? GetActivityReference(ReportDto report, DateTimeOffset? fallbackReference)
    {
        if (report.AlreadyInGestiona)
        {
            var lastGestionaUpload = ParseDate(report.LastGestionaUploadAt);
            if (lastGestionaUpload is not null)
            {
                return lastGestionaUpload;
            }
        }

        return ParseDate(report.LastDownloadedAt) ??
               fallbackReference ??
               ParseDate(report.LastAccess) ??
               ParseDate(report.AccessDate) ??
               ParseDate(report.CreationDate);
    }

    private static DateTimeOffset? MaxDate(IEnumerable<DateTimeOffset> values)
    {
        DateTimeOffset? max = null;
        foreach (var value in values)
        {
            if (max is null || value > max.Value)
            {
                max = value;
            }
        }

        return max;
    }

    private static string ExtractName(JsonElement? name, string fallback)
    {
        if (name is null)
        {
            return fallback;
        }

        if (name.Value.ValueKind == JsonValueKind.String)
        {
            return name.Value.GetString() ?? fallback;
        }

        if (name.Value.ValueKind == JsonValueKind.Object)
        {
            if (name.Value.TryGetProperty("es", out var es) && es.GetString() is { Length: > 0 } esName)
            {
                return esName;
            }

            if (name.Value.TryGetProperty("en", out var en) && en.GetString() is { Length: > 0 } enName)
            {
                return enName;
            }

            foreach (var property in name.Value.EnumerateObject())
            {
                if (!string.IsNullOrWhiteSpace(property.Value.GetString()))
                {
                    return property.Value.GetString()!;
                }
            }
        }

        return fallback;
    }

    private static List<ContextDto> ParseContexts(string body)
    {
        using var document = JsonDocument.Parse(body);
        var contextsElement = document.RootElement;

        if (contextsElement.ValueKind == JsonValueKind.Object &&
            contextsElement.TryGetProperty("contexts", out var contextsProperty))
        {
            contextsElement = contextsProperty;
        }

        if (contextsElement.ValueKind != JsonValueKind.Array)
        {
            return [];
        }

        var contexts = new List<ContextDto>();
        foreach (var item in contextsElement.EnumerateArray())
        {
            var id = GetString(item, "id") ?? string.Empty;
            if (string.IsNullOrWhiteSpace(id))
            {
                continue;
            }

            JsonElement? name = null;
            if (item.TryGetProperty("name", out var nameProp))
            {
                name = nameProp;
            }

            contexts.Add(new ContextDto(id, ExtractName(name, id)));
        }

        return contexts;
    }

    private static List<RawReport> ParseReports(string body)
    {
        using var document = JsonDocument.Parse(body);
        var root = document.RootElement;
        var array = root.ValueKind == JsonValueKind.Array
            ? root
            : FindArrayProperty(root, "rtips", "tips", "items", "data", "entries", "results");

        if (array is null || array.Value.ValueKind != JsonValueKind.Array)
        {
            return [];
        }

        var reports = new List<RawReport>();
        foreach (var item in array.Value.EnumerateArray())
        {
            var id = GetString(item, "id") ?? string.Empty;
            if (string.IsNullOrWhiteSpace(id))
            {
                continue;
            }

            reports.Add(new RawReport
            {
                Id = id,
                Progressive = GetInt32(item, "progressive"),
                ContextId = GetString(item, "context_id", "contextId"),
                CreationDate = GetString(item, "creation_date", "creationDate"),
                UpdateDate = GetString(item, "update_date", "updateDate"),
                ExpirationDate = GetString(item, "expiration_date", "expirationDate"),
                ReminderDate = GetString(item, "reminder_date", "reminderDate"),
                AccessDate = GetString(item, "access_date", "accessDate"),
                LastAccess = GetString(item, "last_access", "lastAccess"),
                WhistleblowerLastAccess = GetString(item, "wb_last_access", "wbLastAccess"),
                Status = GetString(item, "status"),
                Updated = GetBool(item, "updated"),
                Accessible = GetNullableBool(item, "accessible"),
                Label = GetString(item, "label"),
            });
        }

        return reports;
    }

    private static ReportDetailDto ParseReportDetail(
        string reportId,
        string? lastAccess,
        JsonElement root,
        IReadOnlyList<ReportCommentDto>? commentsOverride = null)
    {
        var lastAccessDate = ParseDate(lastAccess);

        var comments = commentsOverride ?? EnumerateArray(root, "comments")
            .Select(item => CreateReportComment(item, lastAccessDate, defaultNewWhenNoReference: true))
            .ToArray();

        var receivers = ParseReportReceivers(root);
        var receiverNames = BuildReceiverNameLookup(receivers);
        comments = comments
            .Select(comment => WithAuthorName(comment, receiverNames))
            .ToArray();
        var whistleblowerFiles = ParseReportFiles(
            root,
            lastAccessDate,
            defaultNewWhenNoReference: true,
            "wbfiles",
            "files");
        var receiverFiles = ParseReportFiles(
                root,
                lastAccessDate,
                defaultNewWhenNoReference: true,
                "rfiles")
            .Select(file => WithAuthorName(file, receiverNames))
            .ToArray();

        return new ReportDetailDto(
            reportId,
            lastAccess,
            comments,
            whistleblowerFiles,
            receiverFiles,
            receivers);
    }

    private static ReportCommentDto CreateReportComment(
        JsonElement item,
        DateTimeOffset? reference,
        bool defaultNewWhenNoReference)
    {
        var creationDate = GetString(item, "creation_date", "creationDate", "date", "created_at", "createdAt");
        var authorId = GetString(item, "author_id", "authorId");
        var activityType = GetCommentActivityType(item);
        if (!string.IsNullOrWhiteSpace(authorId))
        {
            activityType = "receiver";
        }
        else if (string.IsNullOrWhiteSpace(activityType) ||
                 IsUnclassifiedActivityType(activityType))
        {
            activityType = "whistleblower";
        }

        return new ReportCommentDto(
            GetString(item, "id"),
            activityType,
            GetString(item, "content", "text", "message"),
            creationDate,
            IsAfterReference(creationDate, reference, defaultNewWhenNoReference),
            authorId);
    }

    private static string? GetCommentActivityType(JsonElement item)
    {
        if (GetBool(item, "whistleblower", "from_whistleblower", "fromWhistleblower", "is_whistleblower", "isWhistleblower"))
        {
            return "whistleblower";
        }

        if (GetBool(item, "receiver", "recipient", "from_receiver", "fromReceiver", "is_receiver", "isReceiver", "admin", "operator", "staff"))
        {
            return "receiver";
        }

        var direct = GetString(
            item,
            "type",
            "kind",
            "author_type",
            "authorType",
            "author_role",
            "authorRole",
            "role",
            "user_role",
            "userRole",
            "sender",
            "sender_type",
            "senderType",
            "source",
            "actor",
            "creator",
            "from");

        if (!string.IsNullOrWhiteSpace(direct))
        {
            return direct;
        }

        return GetNestedCommentActivityType(item, "author", "user", "creator", "sender", "actor", "owner");
    }

    private static string? GetNestedCommentActivityType(JsonElement item, params string[] names)
    {
        foreach (var name in names)
        {
            if (!item.TryGetProperty(name, out var property))
            {
                continue;
            }

            if (property.ValueKind == JsonValueKind.String)
            {
                return property.GetString();
            }

            if (property.ValueKind != JsonValueKind.Object)
            {
                continue;
            }

            var nested = GetString(
                property,
                "type",
                "kind",
                "role",
                "user_role",
                "userRole",
                "author_type",
                "authorType",
                "name",
                "username",
                "display_name",
                "displayName");

            if (!string.IsNullOrWhiteSpace(nested))
            {
                return nested;
            }
        }

        return null;
    }

    private static bool IsWhistleblowerActivityType(string? value)
    {
        return MatchesAny(value, "whistleblower", "citizen", "source", "tipper", "submitter", "denunciante");
    }

    private static bool IsReceiverActivityType(string? value)
    {
        return MatchesAny(value, "receiver", "recipient", "admin", "administrator", "operator", "staff", "custodian", "moderator", "gestor", "oaaf");
    }

    private static bool IsUnclassifiedActivityType(string? value)
    {
        return !IsWhistleblowerActivityType(value) && !IsReceiverActivityType(value);
    }

    private static bool MatchesAny(string? value, params string[] candidates)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return false;
        }

        var normalized = NormalizeActivityType(value);
        return candidates.Any(candidate =>
        {
            var candidateToken = NormalizeActivityType(candidate);
            return normalized == candidateToken ||
                   normalized.StartsWith(candidateToken + " ", StringComparison.Ordinal) ||
                   normalized.EndsWith(" " + candidateToken, StringComparison.Ordinal) ||
                   normalized.Contains(" " + candidateToken + " ", StringComparison.Ordinal);
        });
    }

    private static string NormalizeActivityType(string value)
    {
        return Regex.Replace(value.Trim().ToLowerInvariant(), @"[^a-z0-9]+", " ").Trim();
    }

    private static ReportFileDto[] ParseReportFiles(
        JsonElement root,
        DateTimeOffset? reference,
        bool defaultNewWhenNoReference,
        params string[] propertyNames)
    {
        return EnumerateArray(root, propertyNames)
            .Select(item =>
            {
                var creationDate = GetString(item, "creation_date", "creationDate");
                return new ReportFileDto(
                    GetString(item, "id"),
                    GetLocalizedString(item, "name", "file_name", "filename"),
                    GetInt64(item, "size"),
                    GetString(item, "content_type", "contentType", "mime_type", "mimetype", "type"),
                    creationDate,
                    IsAfterReference(creationDate, reference, defaultNewWhenNoReference),
                    GetString(item, "author_id", "authorId"));
            })
            .ToArray();
    }

    private static ReportReceiverDto[] ParseReportReceivers(JsonElement root)
    {
        return EnumerateArray(root, "receivers", "recipients")
            .Select(item => new ReportReceiverDto(
                GetString(item, "id") ?? string.Empty,
                GetLocalizedString(item, "name", "display_name", "displayName", "username") ?? "Gestor",
                GetBool(item, "active", "is_active", "isActive")))
            .Where(receiver => !string.IsNullOrWhiteSpace(receiver.Id))
            .ToArray();
    }

    private static IReadOnlyDictionary<string, string> BuildReceiverNameLookup(
        IEnumerable<ReportReceiverDto> receivers)
    {
        return receivers
            .GroupBy(receiver => receiver.Id, StringComparer.OrdinalIgnoreCase)
            .ToDictionary(
                group => group.Key,
                group => group.First().Name,
                StringComparer.OrdinalIgnoreCase);
    }

    private static ReportCommentDto WithAuthorName(
        ReportCommentDto comment,
        IReadOnlyDictionary<string, string> receiverNames)
    {
        if (string.IsNullOrWhiteSpace(comment.AuthorId) ||
            !receiverNames.TryGetValue(comment.AuthorId, out var authorName))
        {
            return comment;
        }

        return comment with { AuthorName = authorName };
    }

    private static ReportFileDto WithAuthorName(
        ReportFileDto file,
        IReadOnlyDictionary<string, string> receiverNames)
    {
        if (string.IsNullOrWhiteSpace(file.AuthorId) ||
            !receiverNames.TryGetValue(file.AuthorId, out var authorName))
        {
            return file;
        }

        return file with { AuthorName = authorName };
    }

    private static bool IsAfterReference(
        string? value,
        DateTimeOffset? reference,
        bool defaultWhenNoReference)
    {
        if (reference is null)
        {
            return defaultWhenNoReference;
        }

        var itemDate = ParseDate(value);
        return itemDate is not null && itemDate > reference;
    }

    private static IEnumerable<JsonElement> EnumerateArray(JsonElement root, params string[] names)
    {
        if (root.ValueKind != JsonValueKind.Object)
        {
            yield break;
        }

        foreach (var name in names)
        {
            if (root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array)
            {
                foreach (var item in property.EnumerateArray())
                {
                    yield return item;
                }

                yield break;
            }
        }
    }

    private static JsonElement? FindArrayProperty(JsonElement element, params string[] names)
    {
        if (element.ValueKind != JsonValueKind.Object)
        {
            return null;
        }

        foreach (var name in names)
        {
            if (element.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.Array)
            {
                return property;
            }
        }

        return null;
    }

    private static string? GetString(JsonElement element, params string[] names)
    {
        foreach (var name in names)
        {
            if (element.TryGetProperty(name, out var property))
            {
                return property.ValueKind switch
                {
                    JsonValueKind.String => property.GetString(),
                    JsonValueKind.Number => property.GetRawText(),
                    _ => null,
                };
            }
        }

        return null;
    }

    private static string? GetLocalizedString(JsonElement element, params string[] names)
    {
        foreach (var name in names)
        {
            if (!element.TryGetProperty(name, out var property))
            {
                continue;
            }

            if (property.ValueKind == JsonValueKind.String)
            {
                return property.GetString();
            }

            if (property.ValueKind == JsonValueKind.Object)
            {
                var value = ExtractName(property, string.Empty);
                return string.IsNullOrWhiteSpace(value) ? null : value;
            }
        }

        return null;
    }

    private static int? GetInt32(JsonElement element, params string[] names)
    {
        foreach (var name in names)
        {
            if (element.TryGetProperty(name, out var property))
            {
                if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value))
                {
                    return value;
                }

                if (property.ValueKind == JsonValueKind.String &&
                    int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
                {
                    return value;
                }
            }
        }

        return null;
    }

    private static long? GetInt64(JsonElement element, params string[] names)
    {
        foreach (var name in names)
        {
            if (element.TryGetProperty(name, out var property))
            {
                if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var value))
                {
                    return value;
                }

                if (property.ValueKind == JsonValueKind.String &&
                    long.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
                {
                    return value;
                }
            }
        }

        return null;
    }

    private static bool GetBool(JsonElement element, params string[] names)
    {
        foreach (var name in names)
        {
            if (element.TryGetProperty(name, out var property))
            {
                if (property.ValueKind is JsonValueKind.True or JsonValueKind.False)
                {
                    return property.GetBoolean();
                }

                if (property.ValueKind == JsonValueKind.String &&
                    bool.TryParse(property.GetString(), out var value))
                {
                    return value;
                }
            }
        }

        return false;
    }

    private static bool? GetNullableBool(JsonElement element, params string[] names)
    {
        foreach (var name in names)
        {
            if (element.TryGetProperty(name, out var property))
            {
                if (property.ValueKind is JsonValueKind.True or JsonValueKind.False)
                {
                    return property.GetBoolean();
                }

                if (property.ValueKind == JsonValueKind.String &&
                    bool.TryParse(property.GetString(), out var value))
                {
                    return value;
                }
            }
        }

        return null;
    }

    private static string SanitizeFileName(string? name, string fallback)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return fallback;
        }

        var sanitized = Regex.Replace(name, "[\\r\\n\\0\"\\\\]", "_").Trim();
        return string.IsNullOrWhiteSpace(sanitized)
            ? fallback
            : sanitized[..Math.Min(200, sanitized.Length)];
    }

    private static string? ExtractFileName(ContentDispositionHeaderValue? contentDisposition)
        => contentDisposition?.FileNameStar ?? contentDisposition?.FileName?.Trim('"');

    private static GlSession ParseAuthSession(string body, string fallbackUsername, string? dpopPrivateKey)
    {
        using var document = JsonDocument.Parse(body);
        var root = document.RootElement;

        var id = GetString(root, "session_id", "sessionId", "session", "sid", "id")
            ?? throw new GlobalLeaksValidationException("GlobalLeaks no devolvió una sesión válida.", 502);
        var username = GetString(root, "username", "name");
        if (string.IsNullOrWhiteSpace(username))
        {
            username = fallbackUsername.Trim();
        }

        var role = GetString(root, "role", "user_role", "userRole");
        var proofOfWorkToken = ParseProofOfWorkToken(root);
        var sessionExpiresAtUtc = ParseSessionExpiration(root);

        return new GlSession(
            id,
            username,
            role,
            dpopPrivateKey,
            proofOfWorkToken,
            sessionExpiresAtUtc);
    }

    private static RefreshedGlobalLeaksSession ParseSessionRefresh(string body)
    {
        using var document = JsonDocument.Parse(body);
        var root = document.RootElement;
        var proofOfWorkToken = ParseProofOfWorkToken(root)
            ?? throw new GlobalLeaksValidationException(
                "GlobalLeaks no devolvio el reto necesario para mantener activa la sesion.",
                StatusCodes.Status502BadGateway);

        return new RefreshedGlobalLeaksSession(
            proofOfWorkToken,
            ParseSessionExpiration(root));
    }

    private static GlobalLeaksProofOfWorkToken? ParseProofOfWorkToken(JsonElement root)
    {
        if (!root.TryGetProperty("token", out var token) ||
            token.ValueKind != JsonValueKind.Object)
        {
            return null;
        }

        var id = GetString(token, "id");
        var salt = GetString(token, "salt");
        return string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(salt)
            ? null
            : new GlobalLeaksProofOfWorkToken(id, salt);
    }

    private static DateTimeOffset? ParseSessionExpiration(JsonElement root)
    {
        if (!root.TryGetProperty("session_expiration", out var expiration))
        {
            return null;
        }

        double unixSeconds;
        if (expiration.ValueKind == JsonValueKind.Number)
        {
            if (!expiration.TryGetDouble(out unixSeconds))
            {
                return null;
            }
        }
        else if (expiration.ValueKind == JsonValueKind.String)
        {
            if (!double.TryParse(
                    expiration.GetString(),
                    NumberStyles.Float,
                    CultureInfo.InvariantCulture,
                    out unixSeconds))
            {
                return null;
            }
        }
        else
        {
            return null;
        }

        if (unixSeconds <= 0 || unixSeconds > DateTimeOffset.MaxValue.ToUnixTimeSeconds())
        {
            return null;
        }

        return DateTimeOffset.FromUnixTimeMilliseconds((long)(unixSeconds * 1000));
    }

    private static async Task EnsureSuccessOrThrowAsync(
        HttpResponseMessage response,
        string endpoint,
        CancellationToken cancellationToken)
    {
        if (response.IsSuccessStatusCode)
        {
            return;
        }

        var body = await ReadBodySafeAsync(response, cancellationToken);
        throw new GlobalLeaksValidationException(
            string.IsNullOrWhiteSpace(body)
                ? $"Error en {endpoint} (código {(int)response.StatusCode})."
                : $"Error en {endpoint} (código {(int)response.StatusCode}): {body}",
            (int)response.StatusCode);
    }

    private static async Task<string> ReadBodySafeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
    {
        try
        {
            return (await response.Content.ReadAsStringAsync(cancellationToken)).Trim();
        }
        catch
        {
            return string.Empty;
        }
    }

    private static bool IsTwoFactorRequiredError(string body)
    {
        if (string.IsNullOrWhiteSpace(body))
        {
            return false;
        }

        return body.Contains("\"error_code\":13", StringComparison.OrdinalIgnoreCase) ||
               body.Contains("\"error_code\": 13", StringComparison.OrdinalIgnoreCase) ||
               body.Contains("Two Factor authentication required", StringComparison.OrdinalIgnoreCase);
    }

    private static bool IsMissingTokenOrSessionError(string body)
    {
        return !string.IsNullOrWhiteSpace(body) &&
               (body.Contains("No token and no session", StringComparison.OrdinalIgnoreCase) ||
                body.Contains("Invalid request: No token", StringComparison.OrdinalIgnoreCase));
    }

    private static bool IsInvalidDpopProof(string body)
    {
        return !string.IsNullOrWhiteSpace(body) &&
               body.Contains("Invalid DPoP proof", StringComparison.OrdinalIgnoreCase);
    }

    private sealed record TokenResponse(string Id, string Salt);
    private sealed record AuthTypeResponse(string Type, string Salt);
    private sealed record ReportActivitySnapshot(
        DateTimeOffset? CitizenLastActivity,
        bool HasNewCitizenComment,
        bool HasNewCitizenFile,
        DateTimeOffset? ReceiverLastActivity,
        bool HasNewReceiverActivity,
        string? ReceiverLastActivityAuthorId,
        string? ReceiverLastActivityAuthorName);

    private sealed record ActivityActorEvent(
        DateTimeOffset? Date,
        string? AuthorId,
        string? AuthorName);

    private sealed record RawReport
    {
        public string Id { get; init; } = string.Empty;
        public int? Progressive { get; init; }
        public string? ContextId { get; init; }
        public string? CreationDate { get; init; }
        public string? UpdateDate { get; init; }
        public string? ExpirationDate { get; init; }
        public string? ReminderDate { get; init; }
        public string? AccessDate { get; init; }
        public string? LastAccess { get; init; }
        public string? WhistleblowerLastAccess { get; init; }
        public string? Status { get; init; }
        public bool Updated { get; init; }
        public bool? Accessible { get; init; }
        public string? Label { get; init; }
    }
}
