using Microsoft.EntityFrameworkCore; using System.Threading; using System.Threading.Tasks; using UtopiaCanteenSystem.Data; using UtopiaCanteenSystem.Helpers; using UtopiaCanteenSystem.Models; namespace UtopiaCanteenSystem.Services; /// /// Handles RFID scan logic: validates input, enforces configurable timeout, /// and saves ScanRecord to SQLite. /// public class RfidService : IRfidService { private readonly IDbContextFactory _dbFactory; private readonly IConfigService _configService; public RfidService(IDbContextFactory dbFactory, IConfigService configService) { _dbFactory = dbFactory; _configService = configService; } public (bool Success, string Message) ProcessScan(string cardId) { var result = ProcessScanDetailed(cardId); return (result.Success, result.Message); } public ScanResult ProcessScanDetailed(string cardId) { if (string.IsNullOrWhiteSpace(cardId)) return new ScanResult(false, "Card ID cannot be empty.", 0); cardId = cardId.Trim(); var nowUtc = DateTime.UtcNow; using var db = _dbFactory.CreateDbContext(); var timeoutSeconds = _configService.GetScanTimeoutSeconds(); if (timeoutSeconds <= 0) timeoutSeconds = 60; var windowStart = nowUtc.AddSeconds(-timeoutSeconds); // Enforce timeout: no duplicate scan within the timeout window var lastInWindow = db.ScanRecords .Where(r => r.CardId == cardId) .Where(r => r.ScanTime >= windowStart) .OrderByDescending(r => r.ScanTime) .FirstOrDefault(); if (lastInWindow != null) { var remaining = GetCooldownRemainingSeconds(nowUtc, lastInWindow.ScanTime, timeoutSeconds); return new ScanResult( false, $"Multiple scans within {FormatTimeout(timeoutSeconds)} are not allowed. Please wait.", remaining); } // Optional: prevent any scan within the timeout window (any card). var lastAnyScanInWindow = db.ScanRecords .Where(r => r.ScanTime >= windowStart) .OrderByDescending(r => r.ScanTime) .FirstOrDefault(); if (lastAnyScanInWindow != null) { var remaining = GetCooldownRemainingSeconds(nowUtc, lastAnyScanInWindow.ScanTime, timeoutSeconds); return new ScanResult( false, $"Only one scan within {FormatTimeout(timeoutSeconds)} is allowed. Please wait.", remaining); } var record = new ScanRecord { CardId = cardId, ScanTime = nowUtc, IsSynced = false }; db.ScanRecords.Add(record); db.SaveChanges(); return new ScanResult(true, "Scan recorded successfully.", 0); } public ScanRecord? GetLastScan() { using var db = _dbFactory.CreateDbContext(); return db.ScanRecords .OrderByDescending(r => r.ScanTime) .FirstOrDefault(); } public int GetTodayScanCount() { // Define "today" by the local calendar day, but ScanTime is stored as UTC. // Convert local day boundaries to UTC for correct comparisons. var startOfTodayLocal = DateTime.Today; var startOfTomorrowLocal = startOfTodayLocal.AddDays(1); var startUtc = startOfTodayLocal.ToUniversalTime(); var endUtc = startOfTomorrowLocal.ToUniversalTime(); using var db = _dbFactory.CreateDbContext(); return db.ScanRecords .Count(r => r.ScanTime >= startUtc && r.ScanTime < endUtc); } public async Task GetTodayScanCountAsync(CancellationToken cancellationToken = default) { // Define "today" by the local calendar day, but ScanTime is stored as UTC. // Convert local day boundaries to UTC for correct comparisons. var startOfTodayLocal = DateTime.Today; var startOfTomorrowLocal = startOfTodayLocal.AddDays(1); var startUtc = startOfTodayLocal.ToUniversalTime(); var endUtc = startOfTomorrowLocal.ToUniversalTime(); await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); return await db.ScanRecords .CountAsync(r => r.ScanTime >= startUtc && r.ScanTime < endUtc, cancellationToken) .ConfigureAwait(false); } public async Task GetTotalScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(cardId)) return 0; cardId = cardId.Trim(); await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); return await db.ScanRecords .CountAsync(r => r.CardId == cardId, cancellationToken) .ConfigureAwait(false); } public async Task GetTodayScanCountForCardAsync(string cardId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(cardId)) return 0; cardId = cardId.Trim(); // Define "today" by the local calendar day, but ScanTime is stored as UTC. // Convert local day boundaries to UTC for correct comparisons. var startOfTodayLocal = DateTime.Today; var startOfTomorrowLocal = startOfTodayLocal.AddDays(1); var startUtc = startOfTodayLocal.ToUniversalTime(); var endUtc = startOfTomorrowLocal.ToUniversalTime(); await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); return await db.ScanRecords .CountAsync(r => r.CardId == cardId && r.ScanTime >= startUtc && r.ScanTime < endUtc, cancellationToken) .ConfigureAwait(false); } private static string FormatTimeout(int seconds) { if (seconds <= 0) return "the timeout window"; var minutes = seconds / 60; var secs = seconds % 60; if (minutes == 0) return secs == 1 ? "1 second" : $"{secs} seconds"; if (secs == 0) return minutes == 1 ? "1 minute" : $"{minutes} minutes"; var minutePart = minutes == 1 ? "1 minute" : $"{minutes} minutes"; var secondPart = secs == 1 ? "1 second" : $"{secs} seconds"; return $"{minutePart} {secondPart}"; } private static int GetCooldownRemainingSeconds(DateTime nowUtc, DateTime lastScanUtc, int timeoutSeconds) { // Remaining = (lastScan + timeout) - now. Use ceiling so UI shows a whole-second countdown. var endUtc = lastScanUtc.AddSeconds(timeoutSeconds); var remaining = (int)Math.Ceiling((endUtc - nowUtc).TotalSeconds); return Math.Max(0, remaining); } }