using Fitness_core_BE.DTOs; using Fitness_core_BE.DTOs.Agent; using Fitness_core_BE.Exceptions; using Fitness_core_BE.Helpers; using Fitness_core_BE.Hubs; using Fitness_core_BE.Infra.UoW; using Fitness_core_BE.Interfaces; using Fitness_core_BE.Interfaces.Services; using Fitness_core_BE.Models; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; using System.Text.Json; namespace Fitness_core_BE.Services { public class AgentService : IAgentService { private static readonly TimeSpan DevicePresenceWindow = TimeSpan.FromSeconds(90); private readonly IUnitOfWork _UoW; private readonly IAgentRepo _agentRepo; private readonly IPairingCodeRepo _pairingCodeRepo; private readonly IDeviceCommandRepo _deviceCommandRepo; private readonly IAccessDeviceRepo _accessDeviceRepo; private readonly IAccessDeviceService _accessDeviceService; private readonly ILogger _logger; private readonly IHubContext _accessHub; public AgentService( IUnitOfWork unitOfWork, IAgentRepo agentRepo, IPairingCodeRepo pairingCodeRepo, IDeviceCommandRepo deviceCommandRepo, IAccessDeviceRepo accessDeviceRepo, IAccessDeviceService accessDeviceService, ILogger logger, IHubContext accessHub) { _UoW = unitOfWork; _agentRepo = agentRepo; _pairingCodeRepo = pairingCodeRepo; _deviceCommandRepo = deviceCommandRepo; _accessDeviceRepo = accessDeviceRepo; _accessDeviceService = accessDeviceService; _logger = logger; _accessHub = accessHub; } public async Task EnrollAsync(EnrollRequest request) { if (request == null || string.IsNullOrWhiteSpace(request.Code)) throw new BadRequestException("Pairing code is required."); if (string.IsNullOrWhiteSpace(request.AgentName)) throw new BadRequestException("Agent name is required."); var pairingCode = await _pairingCodeRepo.GetActiveByCodeAsync(request.Code); if (pairingCode == null) throw new BadRequestException("Pairing code is invalid, used, or expired."); var apiKey = ApiKeyHelper.GenerateApiKey(); var agent = new Agent { AgentId = Guid.NewGuid(), FKClientId = pairingCode.FKClientId, Name = request.AgentName.Trim(), ApiKeyHash = ApiKeyHelper.HashApiKey(apiKey), IsActive = true, CreatedAt = DateTime.UtcNow }; await _UoW.BeginAsync(); try { await _agentRepo.AddAsync(agent); await _pairingCodeRepo.MarkUsedAsync(pairingCode); await _UoW.CommitAsync(); } catch { await _UoW.RollbackAsync(); throw; } return new { agentId = agent.AgentId, apiKey }; } public async Task RegisterDeviceAsync( Guid clientId, Guid agentId, RegisterDeviceRequest request) { if (request == null || string.IsNullOrWhiteSpace(request.DeviceName)) throw new BadRequestException("Device name is required."); var hardwareId = string.IsNullOrWhiteSpace(request.HardwareId) ? null : request.HardwareId.Trim(); AccessDevice? device = null; if (request.DeviceId.HasValue && request.DeviceId.Value != Guid.Empty) { device = await _accessDeviceRepo.GetDeviceByIdAsync(request.DeviceId.Value); if (device != null && device.FKClientId != clientId) throw new ForbiddenException("Device belongs to another client."); } if (device == null && hardwareId != null) { device = await _accessDeviceRepo .GetDeviceByHardwareIdAsync(clientId, hardwareId); } await _UoW.BeginAsync(); try { if (device == null) { device = new AccessDevice { DeviceId = Guid.NewGuid(), FKClientId = clientId, CreatedAt = DateTime.UtcNow }; await _accessDeviceRepo.AddDeviceAsync(device); } device.DeviceName = request.DeviceName.Trim(); device.HardwareId = hardwareId ?? device.HardwareId; device.FKAgentId = agentId; device.IsActive = true; device.LastSeenAt = DateTime.UtcNow; // The row must exist before bindings/commands reference it. await _UoW.SaveChangesAsync(); await _accessDeviceService.ProvisionProfilesToDeviceAsync(device); await _UoW.CommitAsync(); } catch { await _UoW.RollbackAsync(); throw; } return new { deviceId = device.DeviceId }; } public async Task ReconcileDeviceInventoryAsync( Guid clientId, Guid agentId, DeviceInventoryRequest request) { if (request == null || request.DeviceId == Guid.Empty) throw new BadRequestException("Device id is required."); var device = await _accessDeviceRepo .GetActiveDeviceForClientAsync(clientId, request.DeviceId); if (device == null) throw new ForbiddenException("Device does not belong to this client."); if (device.FKAgentId != agentId) throw new ForbiddenException("Device is assigned to another agent."); var now = DateTime.UtcNow; var discovered = 0; var updated = 0; var reportedEmployeeNos = new HashSet(StringComparer.Ordinal); await _UoW.BeginAsync(); try { device.LastSeenAt = now; foreach (var person in request.People ?? new List()) { var employeeNo = person.EmployeeNo?.Trim(); if (string.IsNullOrWhiteSpace(employeeNo)) continue; reportedEmployeeNos.Add(employeeNo); var binding = await _accessDeviceRepo .GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo); if (binding != null) { binding.DeviceReportedName = person.Name; binding.FingerprintCount = Math.Max(0, person.FingerprintCount); binding.ActualAccessState = IsBlocked(person.UserType) ? "Disabled" : "Enabled"; var matchesDesired = binding.ActualAccessState == binding.AccessProfile.DesiredAccessState; binding.ProvisionStatus = matchesDesired ? "Synced" : "Pending"; binding.LastError = matchesDesired ? null : "Terminal access state differs from the platform; repair queued."; binding.LastSyncedAt = now; binding.UpdatedAt = now; if (!matchesDesired) await QueueInventoryRepairAsync(binding, "SetAccessState", now); updated++; continue; } var unlinked = await _accessDeviceRepo .GetUnlinkedEnrollmentByEmployeeNoAsync( request.DeviceId, employeeNo); if (unlinked == null) { unlinked = new UnlinkedDeviceEnrollment { Id = Guid.NewGuid(), FKDeviceId = request.DeviceId, DeviceEmployeeNo = employeeNo, DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0, DiscoveredAt = now }; await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(unlinked); discovered++; } unlinked.DeviceReportedName = person.Name; unlinked.DeviceUserType = person.UserType; unlinked.FingerprintCount = Math.Max(0, person.FingerprintCount); unlinked.LastSeenAt = now; } var missingBindings = await _accessDeviceRepo .GetBindingsForDeviceAsync(request.DeviceId); foreach (var binding in missingBindings.Where( b => !reportedEmployeeNos.Contains(b.DeviceEmployeeNo))) { binding.ProvisionStatus = "Pending"; binding.ActualAccessState = "Unknown"; binding.FingerprintCount = 0; binding.LastError = "Terminal person is missing; recreation queued."; binding.LastSyncedAt = now; binding.UpdatedAt = now; await QueueInventoryRepairAsync(binding, "UpsertPerson", now); } await _UoW.CommitAsync(); } catch { await _UoW.RollbackAsync(); throw; } var total = request.People?.Count ?? 0; await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new { deviceId = request.DeviceId, discovered, updated, total, occurredAt = now }); return new { discovered, updated, total }; } public async Task IngestAttendanceAsync( Guid clientId, Guid agentId, AgentAttendanceRequest request) { if (request == null) throw new BadRequestException("Request body is required."); var device = await _accessDeviceRepo .GetActiveDeviceForClientAsync(clientId, request.DeviceId); if (device == null) throw new ForbiddenException("Device does not belong to this client."); if (device.FKAgentId != agentId) throw new ForbiddenException("Device is assigned to another agent."); var eventCount = request.Events?.Count ?? 0; var attendanceList = new List(); var unknown = 0; foreach (var ev in request.Events ?? new List()) { var employeeNo = ev.EmployeeNoString?.Trim(); if (string.IsNullOrWhiteSpace(employeeNo)) continue; var binding = await _accessDeviceRepo .GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo); if (binding == null) { await UpsertUnknownFromAttendanceAsync(request.DeviceId, employeeNo); unknown++; continue; } Guid? customerId = null; if (string.Equals( binding.AccessProfile.PersonType, "Customer", StringComparison.OrdinalIgnoreCase)) { customerId = await _accessDeviceRepo .GetCustomerIdForUserAtClientAsync( binding.AccessProfile.FKUserId, clientId); } attendanceList.Add(new AttendanceDTO { DeviceId = request.DeviceId, CustomerId = customerId, UserId = binding.AccessProfile.FKUserId, AccessProfileId = binding.FKAccessProfileId, DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0, DeviceEmployeeNo = employeeNo, EventTime = ev.Time, EventType = string.IsNullOrWhiteSpace(ev.AttendanceStatus) ? "Unknown" : ev.AttendanceStatus }); } device.LastSeenAt = DateTime.UtcNow; if (unknown > 0 || eventCount > 0) await _UoW.SaveChangesAsync(); var inserted = await _accessDeviceService.SaveAttendanceRecords(clientId, attendanceList); if (inserted > 0 || unknown > 0) { await PublishAccessEventAsync(clientId, "AttendanceUpdated", new { deviceId = request.DeviceId, inserted, unknown, received = eventCount, occurredAt = DateTime.UtcNow }); } if (unknown > 0) { await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new { deviceId = request.DeviceId, discovered = unknown, updated = 0, total = unknown, occurredAt = DateTime.UtcNow }); } _logger.LogInformation( "Attendance ingest: client {ClientId}, device {DeviceId}, {Received} received, " + "{Unknown} unknown, {Inserted} inserted.", clientId, request.DeviceId, eventCount, unknown, inserted); return new { inserted, unknown }; } public async Task GetPendingCommandsAsync(Guid clientId, Guid agentId) { var now = DateTime.UtcNow; // Close commands whose final lease expired without a successful report. var exhausted = await _deviceCommandRepo .GetExhaustedLeasesAsync(clientId, now); foreach (var command in exhausted) { command.Status = "Failed"; command.ProcessedAt = now; command.ResultJson = command.ResultJson ?? "{\"detail\":\"Command lease expired.\"}"; if (command.AccessDeviceBinding != null) { command.AccessDeviceBinding.ProvisionStatus = "Failed"; command.AccessDeviceBinding.LastError = "Command lease expired."; command.AccessDeviceBinding.UpdatedAt = now; } } var commands = await _deviceCommandRepo .GetLeaseableCommandsAsync(clientId, agentId, now, 100); foreach (var command in commands) { command.Status = "InProgress"; command.AttemptCount++; command.LeaseExpiresAt = now.AddSeconds(90); } await _UoW.SaveChangesAsync(); return commands.Select(c => new { id = c.Id, commandType = c.CommandType, payloadJson = c.PayloadJson, fkDeviceId = c.FKDeviceId, attempt = c.AttemptCount, createdAt = c.CreatedAt }).ToList(); } public async Task SubmitCommandResultAsync( Guid clientId, Guid agentId, Guid commandId, CommandResultRequest request) { if (request == null) throw new BadRequestException("Request body is required."); var command = await _deviceCommandRepo.GetForAgentResultAsync(commandId) ?? throw new NotFoundException($"Command {commandId} not found."); if (command.FKClientId != clientId) throw new ForbiddenException("Command does not belong to this client."); if (command.AccessDevice?.FKAgentId != agentId) throw new ForbiddenException("Command belongs to another agent."); var now = DateTime.UtcNow; var binding = command.AccessDeviceBinding; var versionIsCurrent = binding == null || command.DesiredVersion == binding.AccessProfile.DesiredVersion; // A face photo carries its image in the payload and reports enrolment state on // the profile, not the binding's access state -- so it takes its own path. var isFacePhoto = command.CommandType == "UpsertFacePhoto"; if (isFacePhoto) { HandleFacePhotoResult(command, binding, request, versionIsCurrent, now); } else if (request.Success) { command.Status = "Completed"; command.ProcessedAt = now; command.LeaseExpiresAt = null; if (binding != null && versionIsCurrent) { binding.ProvisionStatus = "Synced"; binding.ActualAccessState = ReadEnabled(command.PayloadJson) ? "Enabled" : "Disabled"; binding.LastError = null; binding.LastSyncedAt = now; binding.UpdatedAt = now; } } else if (command.AttemptCount < command.MaxAttempts && versionIsCurrent) { command.Status = "Pending"; command.LeaseExpiresAt = null; command.NextAttemptAt = now.AddSeconds(Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300)); if (binding != null) { binding.ProvisionStatus = "Pending"; binding.LastError = ExtractResultDetail(request.ResultJson); binding.UpdatedAt = now; } } else { command.Status = "Failed"; command.ProcessedAt = now; command.LeaseExpiresAt = null; if (binding != null && versionIsCurrent) { binding.ProvisionStatus = "Failed"; binding.LastError = ExtractResultDetail(request.ResultJson); binding.UpdatedAt = now; } } command.ResultJson = request.ResultJson; await _UoW.SaveChangesAsync(); if (isFacePhoto) { var profile = binding?.AccessProfile; await PublishAccessEventAsync(clientId, "FacePhotoUpdated", new { commandId = command.Id, bindingId = command.FKAccessDeviceBindingId, accessProfileId = profile?.AccessProfileId, facePhotoStatus = profile?.FacePhotoStatus, hasFacePhoto = profile?.HasFacePhoto, error = profile?.FacePhotoError, occurredAt = now }); } else { await PublishAccessEventAsync(clientId, "AccessStateUpdated", new { commandId = command.Id, bindingId = command.FKAccessDeviceBindingId, status = command.Status, success = request.Success, occurredAt = now }); } return new { command.Id, command.Status, command.AttemptCount, command.MaxAttempts }; } /// /// Applies a face-photo command result to the access profile and, on any terminal /// state, drops the image from the command payload. The photo is only ever held /// while it is in flight to a terminal -- never stored. /// private void HandleFacePhotoResult( DeviceCommand command, AccessDeviceBinding? binding, CommandResultRequest request, bool versionIsCurrent, DateTime now) { var profile = binding?.AccessProfile; if (request.Success) { command.Status = "Completed"; command.ProcessedAt = now; command.LeaseExpiresAt = null; command.PayloadJson = string.Empty; // purge the image if (profile != null && versionIsCurrent) { profile.HasFacePhoto = true; profile.FacePhotoStatus = "Synced"; profile.FacePhotoError = null; // The agent enrols the face under the person's employeeNo, so that is the // FPID. It is read from the result when the agent reports one, and falls // back to the binding otherwise -- either way this must be populated, as // it is the only handle a later replace/delete has. profile.FacePhotoFPID = ExtractFacePhotoFpid(request.ResultJson) ?? binding?.DeviceEmployeeNo; profile.FacePhotoUpdatedAt = now; profile.UpdatedAt = now; } return; } // A rejected face (not frontal, eyes closed, poor lighting) will be rejected the // same way on every retry, so the payload is kept only while retries remain. if (command.AttemptCount < command.MaxAttempts && versionIsCurrent) { command.Status = "Pending"; command.LeaseExpiresAt = null; command.NextAttemptAt = now.AddSeconds( Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300)); if (profile != null) { profile.FacePhotoStatus = "Pending"; profile.FacePhotoError = ExtractResultDetail(request.ResultJson); profile.FacePhotoUpdatedAt = now; } return; } command.Status = "Failed"; command.ProcessedAt = now; command.LeaseExpiresAt = null; command.PayloadJson = string.Empty; // purge the image if (profile != null && versionIsCurrent) { profile.FacePhotoStatus = "Failed"; profile.FacePhotoError = ExtractResultDetail(request.ResultJson); profile.FacePhotoUpdatedAt = now; profile.UpdatedAt = now; } } /// Pulls the Hikvision face-record id out of the agent's result JSON. private static string? ExtractFacePhotoFpid(string? resultJson) { if (string.IsNullOrWhiteSpace(resultJson)) return null; try { using var doc = JsonDocument.Parse(resultJson); foreach (var name in new[] { "fpid", "FPID", "faceId", "faceURL" }) { if (doc.RootElement.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String) { var id = value.GetString(); if (!string.IsNullOrWhiteSpace(id)) return id.Length <= 64 ? id : id[..64]; } } } catch { } return null; } public async Task HeartbeatAsync( Guid clientId, Guid agentId, AgentHeartbeatRequest request) { var serverTime = DateTime.UtcNow; await _agentRepo.UpdateLastSeenAsync(agentId, serverTime); var reports = (request.Devices ?? new List()) .Where(report => report.DeviceId != Guid.Empty) .GroupBy(report => report.DeviceId) .Select(group => group.Last()) .ToList(); var reportedIds = reports.Select(report => report.DeviceId).ToList(); var devices = reportedIds.Count == 0 ? new List() : await _accessDeviceRepo.GetAgentDevicesAsync( clientId, agentId, reportedIds); foreach (var device in devices) { var report = reports.First(item => item.DeviceId == device.DeviceId); device.IsReachable = report.IsOnline; device.LastHealthReportedAt = serverTime; if (report.IsOnline) device.LastSeenAt = serverTime; } await _UoW.SaveChangesAsync(); var presence = devices.Select(device => new { deviceId = device.DeviceId, isOnline = device.IsActive && device.IsReachable, lastSeenAt = device.LastSeenAt, presenceExpiresAt = device.IsActive && device.IsReachable ? serverTime.Add(DevicePresenceWindow) : (DateTime?)null }).ToList(); if (presence.Count > 0) { await PublishAccessEventAsync(clientId, "DevicePresenceUpdated", new { agentId, devices = presence, occurredAt = serverTime }); } return new { serverTime, devices = presence }; } private async Task UpsertUnknownFromAttendanceAsync(Guid deviceId, string employeeNo) { var existing = await _accessDeviceRepo .GetUnlinkedEnrollmentByEmployeeNoAsync(deviceId, employeeNo); if (existing != null) { existing.LastSeenAt = DateTime.UtcNow; return; } await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(new UnlinkedDeviceEnrollment { Id = Guid.NewGuid(), FKDeviceId = deviceId, DeviceEmployeeNo = employeeNo, DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0, DiscoveredAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow }); } private async Task QueueInventoryRepairAsync( AccessDeviceBinding binding, string commandType, DateTime now) { var profile = binding.AccessProfile; var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync( binding.AccessDeviceBindingId, commandType, profile.DesiredVersion); if (alreadyQueued) return; var payloadJson = JsonSerializer.Serialize(new { bindingId = binding.AccessDeviceBindingId, accessProfileId = profile.AccessProfileId, employeeNo = binding.DeviceEmployeeNo, name = profile.DisplayName, enabled = profile.DesiredAccessState == "Enabled", desiredVersion = profile.DesiredVersion }); await _deviceCommandRepo.AddAsync(new DeviceCommand { Id = Guid.NewGuid(), FKClientId = profile.FKClientId, FKDeviceId = binding.FKDeviceId, FKAccessDeviceBindingId = binding.AccessDeviceBindingId, CommandType = commandType, PayloadJson = payloadJson, DesiredVersion = profile.DesiredVersion, Status = "Pending", MaxAttempts = 5, NextAttemptAt = now, CreatedAt = now }); } private static bool IsBlocked(string? userType) => string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase); private static bool ReadEnabled(string payloadJson) { try { using var doc = JsonDocument.Parse(payloadJson); return !doc.RootElement.TryGetProperty("enabled", out var enabled) || enabled.GetBoolean(); } catch { return true; } } private static string ExtractResultDetail(string? resultJson) { if (string.IsNullOrWhiteSpace(resultJson)) return "Device command failed."; try { using var doc = JsonDocument.Parse(resultJson); if (doc.RootElement.TryGetProperty("detail", out var detail)) return detail.GetString() ?? resultJson; } catch { } return resultJson.Length <= 1000 ? resultJson : resultJson[..1000]; } private async Task PublishAccessEventAsync( Guid clientId, string eventName, object payload) { try { await _accessHub.Clients .Group(AccessHub.ClientGroup(clientId)) .SendAsync(eventName, payload); } catch (Exception ex) { // Device ingestion is authoritative; a transient socket/backplane // failure must never make the agent retry a committed database write. _logger.LogWarning( ex, "Could not publish {EventName} for client {ClientId}.", eventName, clientId); } } } }