using Elecciones.Database; using Elecciones.Database.Entities; using Elecciones.Infrastructure.Services; using Microsoft.EntityFrameworkCore; namespace Elecciones.Worker; public class LowPriorityDataWorker : BackgroundService { private readonly ILogger _logger; private readonly SharedTokenService _tokenService; private readonly IServiceProvider _serviceProvider; private readonly IElectoralApiService _apiService; // Una variable para rastrear la tarea de telegramas, si está en ejecución. private Task? _telegramasTask; public LowPriorityDataWorker( ILogger logger, SharedTokenService tokenService, IServiceProvider serviceProvider, IElectoralApiService apiService) { _logger = logger; _tokenService = tokenService; _serviceProvider = serviceProvider; _apiService = apiService; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Worker de Baja Prioridad iniciado."); // La sincronización inicial sigue siendo un paso de bloqueo, es necesario. await SincronizarCatalogosMaestrosAsync(stoppingToken); while (!stoppingToken.IsCancellationRequested) { _logger.LogInformation("--- Iniciando Ciclo de Datos de Baja Prioridad ---"); var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken); if (string.IsNullOrEmpty(authToken)) { _logger.LogError("Ciclo de Baja Prioridad: No se pudo obtener token. Reintentando en 1 minuto."); await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); continue; } // --- LÓGICA DE EJECUCIÓN INDEPENDIENTE --- // 1. TAREA DE BANCAS: Siempre se ejecuta y se espera. Es rápida. _logger.LogInformation("Iniciando sondeo de Bancas..."); await SondearProyeccionBancasAsync(authToken, stoppingToken); _logger.LogInformation("Sondeo de Bancas completado."); // 2. TAREA DE TELEGRAMAS: "Dispara y Olvida" de forma segura. // Comprobamos si la tarea anterior de telegramas ya ha terminado. if (_telegramasTask == null || _telegramasTask.IsCompleted) { _logger.LogInformation("Iniciando sondeo de Telegramas en segundo plano..."); // Lanzamos la tarea de telegramas pero NO la esperamos con 'await'. // Guardamos una referencia a la tarea en nuestra variable de estado. _telegramasTask = SondearNuevosTelegramasAsync(authToken, stoppingToken); } else { // Si la descarga anterior todavía está en curso, nos saltamos este sondeo // para no acumular tareas y sobrecargar el sistema. _logger.LogInformation("El sondeo de telegramas anterior sigue en ejecución. Se omitirá en este ciclo."); } _logger.LogInformation("--- Ciclo de Datos de Baja Prioridad completado. Esperando 5 minutos. ---"); try { await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } catch (TaskCanceledException) { break; } } } /// /// Descarga y sincroniza los catálogos base (Categorías, Ámbitos, Agrupaciones) /// desde la API a la base de datos local. Se ejecuta una sola vez al iniciar el worker. /// Utiliza una estrategia de guardado en lotes para manejar grandes volúmenes de datos /// sin sobrecargar la base de datos. /// /// El token de cancelación para detener la operación. private async Task SincronizarCatalogosMaestrosAsync(CancellationToken stoppingToken) { try { _logger.LogInformation("Iniciando sincronización de catálogos maestros..."); // --- CORRECCIÓN: Usar el _tokenService inyectado --- var authToken = await _tokenService.GetValidAuthTokenAsync(stoppingToken); if (string.IsNullOrEmpty(authToken) || stoppingToken.IsCancellationRequested) { _logger.LogError("No se pudo obtener token para la sincronización de catálogos. La operación se cancela."); return; } // Creamos un scope de servicios para obtener una instancia fresca de DbContext. using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); // PASO 2: Sincronizar las categorías electorales. // Es un catálogo pequeño y es la base para las siguientes consultas. var categoriasApi = await _apiService.GetCategoriasAsync(authToken); if (categoriasApi is null || !categoriasApi.Any()) { _logger.LogWarning("La API no devolvió datos para el catálogo de Categorías. La sincronización no puede continuar."); return; } var distinctCategorias = categoriasApi.GroupBy(c => c.CategoriaId).Select(g => g.First()).OrderBy(c => c.Orden).ToList(); _logger.LogInformation("Se procesarán {count} categorías electorales.", distinctCategorias.Count); var categoriasEnDb = await dbContext.CategoriasElectorales.ToDictionaryAsync(c => c.Id, c => c, stoppingToken); foreach (var categoriaDto in distinctCategorias) { if (!categoriasEnDb.ContainsKey(categoriaDto.CategoriaId)) { dbContext.CategoriasElectorales.Add(new CategoriaElectoral { Id = categoriaDto.CategoriaId, Nombre = categoriaDto.Nombre, Orden = categoriaDto.Orden }); } } // Guardamos las categorías primero para asegurar su existencia. await dbContext.SaveChangesAsync(stoppingToken); // PASO 3: Cargar los catálogos existentes en memoria para una comparación eficiente. // Esto evita hacer miles de consultas a la BD dentro de un bucle. // Para los ámbitos, creamos una clave única robusta que funciona incluso con campos nulos. var ambitosEnDb = new Dictionary(); var todosLosAmbitos = await dbContext.AmbitosGeograficos.ToListAsync(stoppingToken); foreach (var ambito in todosLosAmbitos) { string clave = $"{ambito.NivelId}|{ambito.DistritoId}|{ambito.SeccionProvincialId}|{ambito.SeccionId}|{ambito.MunicipioId}|{ambito.CircuitoId}|{ambito.EstablecimientoId}|{ambito.MesaId}"; if (!ambitosEnDb.ContainsKey(clave)) { ambitosEnDb.Add(clave, ambito); } } var agrupacionesEnDb = await dbContext.AgrupacionesPoliticas.ToDictionaryAsync(a => a.Id, a => a, stoppingToken); // Variable para llevar la cuenta del total de registros insertados. int totalCambiosGuardados = 0; // PASO 4: Iterar sobre cada categoría para sincronizar sus ámbitos y agrupaciones. foreach (var categoria in distinctCategorias) { if (stoppingToken.IsCancellationRequested) break; _logger.LogInformation("--- Sincronizando datos para la categoría: {Nombre} (ID: {Id}) ---", categoria.Nombre, categoria.CategoriaId); var catalogoDto = await _apiService.GetCatalogoAmbitosAsync(authToken, categoria.CategoriaId); if (catalogoDto != null) { // 4.1 - Procesar y añadir ÁMBITOS nuevos al DbContext foreach (var ambitoDto in catalogoDto.Ambitos) { string claveUnica = $"{ambitoDto.NivelId}|{ambitoDto.CodigoAmbitos.DistritoId}|{ambitoDto.CodigoAmbitos.SeccionProvincialId}|{ambitoDto.CodigoAmbitos.SeccionId}|{ambitoDto.CodigoAmbitos.MunicipioId}|{ambitoDto.CodigoAmbitos.CircuitoId}|{ambitoDto.CodigoAmbitos.EstablecimientoId}|{ambitoDto.CodigoAmbitos.MesaId}"; if (!ambitosEnDb.ContainsKey(claveUnica)) { var nuevoAmbito = new AmbitoGeografico { Nombre = ambitoDto.Nombre, NivelId = ambitoDto.NivelId, DistritoId = ambitoDto.CodigoAmbitos.DistritoId, SeccionProvincialId = ambitoDto.CodigoAmbitos.SeccionProvincialId, SeccionId = ambitoDto.CodigoAmbitos.SeccionId, MunicipioId = ambitoDto.CodigoAmbitos.MunicipioId, CircuitoId = ambitoDto.CodigoAmbitos.CircuitoId, EstablecimientoId = ambitoDto.CodigoAmbitos.EstablecimientoId, MesaId = ambitoDto.CodigoAmbitos.MesaId, }; dbContext.AmbitosGeograficos.Add(nuevoAmbito); ambitosEnDb.Add(claveUnica, nuevoAmbito); // Añadir también al diccionario en memoria } } // 4.2 - Procesar y añadir AGRUPACIONES nuevas al DbContext var provincia = catalogoDto.Ambitos.FirstOrDefault(a => a.NivelId == 10); if (provincia != null && !string.IsNullOrEmpty(provincia.CodigoAmbitos.DistritoId)) { // Usamos un try-catch porque no todas las categorías tienen agrupaciones a nivel provincial. try { var agrupacionesApi = await _apiService.GetAgrupacionesAsync(authToken, provincia.CodigoAmbitos.DistritoId, categoria.CategoriaId); if (agrupacionesApi != null && agrupacionesApi.Any()) { foreach (var agrupacionDto in agrupacionesApi) { if (!agrupacionesEnDb.ContainsKey(agrupacionDto.IdAgrupacion)) { var nuevaAgrupacion = new AgrupacionPolitica { Id = agrupacionDto.IdAgrupacion, IdTelegrama = agrupacionDto.IdAgrupacionTelegrama, Nombre = agrupacionDto.NombreAgrupacion }; dbContext.AgrupacionesPoliticas.Add(nuevaAgrupacion); agrupacionesEnDb.Add(nuevaAgrupacion.Id, nuevaAgrupacion); } } } } catch (Exception ex) { _logger.LogWarning(ex, "No se pudieron obtener agrupaciones para la categoría '{catNombre}' ({catId}).", categoria.Nombre, categoria.CategoriaId); } } } // Después de procesar todos los ámbitos y agrupaciones de UNA categoría, guardamos los cambios. // Esto divide la inserción masiva de ~50,000 registros en 3 transacciones más pequeñas, // evitando timeouts y fallos en la base de datos. if (dbContext.ChangeTracker.HasChanges()) { int cambiosEnLote = await dbContext.SaveChangesAsync(stoppingToken); totalCambiosGuardados += cambiosEnLote; _logger.LogInformation("Guardados {count} registros de catálogo para la categoría '{catNombre}'.", cambiosEnLote, categoria.Nombre); } } // Ya no hay un SaveChangesAsync() gigante aquí. _logger.LogInformation("{count} nuevos registros de catálogo han sido guardados en total.", totalCambiosGuardados); _logger.LogInformation("Sincronización de catálogos maestros finalizada."); } catch (Exception ex) { _logger.LogError(ex, "Ocurrió un error CRÍTICO durante la sincronización de catálogos."); } } /// /// Sondea la proyección de bancas a nivel Provincial y por Sección Electoral. /// Esta versión es completamente robusta: maneja respuestas de API vacías o con fechas mal formadas, /// guarda la CategoriaId y usa una transacción atómica para la escritura en base de datos. /// /// El token de autenticación válido para la sesión. /// El token de cancelación para detener la operación. private async Task SondearProyeccionBancasAsync(string authToken, CancellationToken stoppingToken) { try { using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var categoriasDeBancas = await dbContext.CategoriasElectorales .AsNoTracking() .Where(c => c.Nombre.Contains("SENADORES") || c.Nombre.Contains("DIPUTADOS")) .ToListAsync(stoppingToken); var provincia = await dbContext.AmbitosGeograficos .AsNoTracking() .FirstOrDefaultAsync(a => a.NivelId == 10, stoppingToken); var seccionesElectorales = await dbContext.AmbitosGeograficos .AsNoTracking() .Where(a => a.NivelId == 20 && a.DistritoId != null && a.SeccionProvincialId != null) .ToListAsync(stoppingToken); if (!categoriasDeBancas.Any() || provincia == null) { _logger.LogWarning("No se encontraron categorías de bancas o el ámbito provincial en la BD. Omitiendo sondeo de bancas."); return; } _logger.LogInformation("Iniciando sondeo de Bancas a nivel Provincial y para {count} Secciones Electorales...", seccionesElectorales.Count); var todasLasProyecciones = new List(); bool hasReceivedAnyNewData = false; // Bucle para el nivel Provincial foreach (var categoria in categoriasDeBancas) { if (stoppingToken.IsCancellationRequested) break; var repartoBancasDto = await _apiService.GetBancasAsync(authToken, provincia.DistritoId!, null, categoria.Id); if (repartoBancasDto?.RepartoBancas is { Count: > 0 } bancas) { hasReceivedAnyNewData = true; // --- CORRECCIÓN DE SEGURIDAD: Usar TryParse para la fecha --- DateTime fechaTotalizacion; if (!DateTime.TryParse(repartoBancasDto.FechaTotalizacion, out var parsedDate)) { // Si la fecha es inválida (nula, vacía, mal formada), lo registramos y usamos la hora actual como respaldo. _logger.LogWarning("No se pudo parsear FechaTotalizacion ('{dateString}') para bancas provinciales. Usando la hora actual.", repartoBancasDto.FechaTotalizacion); fechaTotalizacion = DateTime.UtcNow; } else { fechaTotalizacion = parsedDate.ToUniversalTime(); } foreach (var banca in bancas) { todasLasProyecciones.Add(new ProyeccionBanca { AmbitoGeograficoId = provincia.Id, AgrupacionPoliticaId = banca.IdAgrupacion, NroBancas = banca.NroBancas, CategoriaId = categoria.Id, FechaTotalizacion = fechaTotalizacion }); } } } // Bucle para el nivel de Sección Electoral foreach (var seccion in seccionesElectorales) { if (stoppingToken.IsCancellationRequested) break; foreach (var categoria in categoriasDeBancas) { if (stoppingToken.IsCancellationRequested) break; var repartoBancasDto = await _apiService.GetBancasAsync(authToken, seccion.DistritoId!, seccion.SeccionProvincialId!, categoria.Id); if (repartoBancasDto?.RepartoBancas is { Count: > 0 } bancas) { hasReceivedAnyNewData = true; // --- APLICAMOS LA MISMA CORRECCIÓN DE SEGURIDAD AQUÍ --- DateTime fechaTotalizacion; if (!DateTime.TryParse(repartoBancasDto.FechaTotalizacion, out var parsedDate)) { _logger.LogWarning("No se pudo parsear FechaTotalizacion ('{dateString}') para bancas de sección. Usando la hora actual.", repartoBancasDto.FechaTotalizacion); fechaTotalizacion = DateTime.UtcNow; } else { fechaTotalizacion = parsedDate.ToUniversalTime(); } foreach (var banca in bancas) { todasLasProyecciones.Add(new ProyeccionBanca { AmbitoGeograficoId = seccion.Id, AgrupacionPoliticaId = banca.IdAgrupacion, NroBancas = banca.NroBancas, CategoriaId = categoria.Id, FechaTotalizacion = fechaTotalizacion }); } } } } if (hasReceivedAnyNewData) { _logger.LogInformation("Se recibieron datos válidos de bancas. Procediendo a actualizar la base de datos..."); await using var transaction = await dbContext.Database.BeginTransactionAsync(stoppingToken); await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM ProyeccionesBancas", stoppingToken); await dbContext.ProyeccionesBancas.AddRangeAsync(todasLasProyecciones, stoppingToken); await dbContext.SaveChangesAsync(stoppingToken); await transaction.CommitAsync(stoppingToken); _logger.LogInformation("La tabla de proyecciones ha sido actualizada con {count} registros.", todasLasProyecciones.Count); } else { _logger.LogInformation("Sondeo de Bancas completado. No se encontraron datos nuevos de proyección, la tabla no fue modificada."); } } catch (OperationCanceledException) { _logger.LogInformation("Sondeo de bancas cancelado."); } catch (Exception ex) { _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Bancas."); } } /// /// Busca y descarga nuevos telegramas de forma masiva y concurrente. /// Este método crea una lista de todas las combinaciones de Partido/Categoría, /// las consulta a la API con un grado de paralelismo controlado, y cada tarea concurrente /// maneja su propia lógica de descarga y guardado en la base de datos. /// /// El token de autenticación válido para la sesión. /// El token de cancelación para detener la operación. private async Task SondearNuevosTelegramasAsync(string authToken, CancellationToken stoppingToken) { try { _logger.LogInformation("--- Iniciando sondeo de Nuevos Telegramas (modo de bajo perfil) ---"); using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var partidos = await dbContext.AmbitosGeograficos .AsNoTracking() .Where(a => a.NivelId == 30 && a.DistritoId != null && a.SeccionId != null) .ToListAsync(stoppingToken); var categorias = await dbContext.CategoriasElectorales .AsNoTracking() .ToListAsync(stoppingToken); if (!partidos.Any() || !categorias.Any()) return; // --- LÓGICA DE GOTEO LENTO --- // Procesamos una combinación (partido/categoría) a la vez. foreach (var partido in partidos) { foreach (var categoria in categorias) { // Si la aplicación se apaga, salimos inmediatamente. if (stoppingToken.IsCancellationRequested) return; // Obtenemos la lista de IDs. var listaTelegramasApi = await _apiService.GetTelegramasTotalizadosAsync(authToken, partido.DistritoId!, partido.SeccionId!, categoria.Id); if (listaTelegramasApi is { Count: > 0 }) { // Usamos un DbContext propio para este bloque para asegurar que los cambios se guarden. using var innerScope = _serviceProvider.CreateScope(); var innerDbContext = innerScope.ServiceProvider.GetRequiredService(); var idsYaEnDb = await innerDbContext.Telegramas .Where(t => listaTelegramasApi.Contains(t.Id)) .Select(t => t.Id) .ToListAsync(stoppingToken); var nuevosTelegramasIds = listaTelegramasApi.Except(idsYaEnDb).ToList(); if (nuevosTelegramasIds.Any()) { _logger.LogInformation("Se encontraron {count} telegramas nuevos en '{partido}' para '{cat}'. Descargando...", nuevosTelegramasIds.Count, partido.Nombre, categoria.Nombre); // Descargamos los archivos de uno en uno, con una pausa entre cada uno. foreach (var mesaId in nuevosTelegramasIds) { if (stoppingToken.IsCancellationRequested) return; var telegramaFile = await _apiService.GetTelegramaFileAsync(authToken, mesaId); if (telegramaFile != null) { var nuevoTelegrama = new Telegrama { Id = telegramaFile.NombreArchivo, AmbitoGeograficoId = partido.Id, ContenidoBase64 = telegramaFile.Imagen, FechaEscaneo = DateTime.Parse(telegramaFile.FechaEscaneo).ToUniversalTime(), FechaTotalizacion = DateTime.Parse(telegramaFile.FechaTotalizacion).ToUniversalTime() }; await innerDbContext.Telegramas.AddAsync(nuevoTelegrama, stoppingToken); } // PAUSA DELIBERADA: Esperamos un poco para no parecer un bot. await Task.Delay(250, stoppingToken); // 250ms de espera = 4 peticiones/segundo máximo. } await innerDbContext.SaveChangesAsync(stoppingToken); } } // PAUSA DELIBERADA: Esperamos un poco entre cada consulta de lista de telegramas. await Task.Delay(100, stoppingToken); } } _logger.LogInformation("Sondeo de Telegramas completado."); } catch (OperationCanceledException) { _logger.LogInformation("Sondeo de telegramas cancelado."); } catch (Exception ex) { _logger.LogError(ex, "Ocurrió un error CRÍTICO en el sondeo de Telegramas."); } } }