using Microsoft.EntityFrameworkCore; using System.Net; using System.Net.Sockets; 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 scan events to SQLite (lunch_order_transactions table). /// 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 interval = _configService.GetScanInterval(); var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds); var windowStart = nowUtc.AddSeconds(-timeoutSeconds); // Enforce timeout: no duplicate scan within the timeout window var lastInWindow = db.LunchOrderTransactions .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, $"One order per customer within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.", remaining); } // Optional: prevent any scan within the timeout window (any card). var lastAnyScanInWindow = db.LunchOrderTransactions .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 order at a time within {FormatTimeout(timeoutSeconds)}. Ask this customer to rescan after countdown.", remaining); } var record = new ScanRecord { CardId = cardId, ScanTime = nowUtc, IsSynced = false, SiteId = _configService.GetSiteId(), DeviceId = _configService.GetDeviceId(), IpAddress = GetLocalIpAddress() }; db.LunchOrderTransactions.Add(record); db.SaveChanges(); return new ScanResult(true, "Order recorded successfully.", 0); } public ScanRecord? GetLastScan() { using var db = _dbFactory.CreateDbContext(); return db.LunchOrderTransactions .OrderByDescending(r => r.ScanTime) .FirstOrDefault(); } public IReadOnlyList GetLastScans(int count) { if (count <= 0) return Array.Empty(); using var db = _dbFactory.CreateDbContext(); return db.LunchOrderTransactions .OrderByDescending(r => r.ScanTime) .Take(count) .ToList(); } public IReadOnlyList GetScansForToday() { var startOfTodayLocal = DateTime.Today; var startOfTomorrowLocal = startOfTodayLocal.AddDays(1); var startUtc = startOfTodayLocal.ToUniversalTime(); var endUtc = startOfTomorrowLocal.ToUniversalTime(); using var db = _dbFactory.CreateDbContext(); return db.LunchOrderTransactions .Where(r => r.ScanTime >= startUtc && r.ScanTime < endUtc) .OrderByDescending(r => r.ScanTime) .ToList(); } 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.LunchOrderTransactions .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.LunchOrderTransactions .CountAsync(r => r.ScanTime >= startUtc && r.ScanTime < endUtc, cancellationToken) .ConfigureAwait(false); } public async Task GetTotalScanCountAsync(CancellationToken cancellationToken = default) { await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); return await db.LunchOrderTransactions.CountAsync(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.LunchOrderTransactions .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.LunchOrderTransactions .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); } private static string GetLocalIpAddress() { try { var host = Dns.GetHostEntry(Dns.GetHostName()); var ip = host.AddressList.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork); return ip?.ToString() ?? string.Empty; } catch { return string.Empty; } } }