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). /// Validates card against local HRMS (employee lookup) before accepting scan. /// public class RfidService : IRfidService { private readonly IDbContextFactory _dbFactory; private readonly IConfigService _configService; private readonly IEmployeeLookupService _employeeLookup; private readonly IMealSessionResolver _mealSessionResolver; public RfidService(IDbContextFactory dbFactory, IConfigService configService, IEmployeeLookupService employeeLookup, IMealSessionResolver mealSessionResolver) { _dbFactory = dbFactory; _configService = configService; _employeeLookup = employeeLookup; _mealSessionResolver = mealSessionResolver; } 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(); // HRMS lookup: reject if card not registered var employee = _employeeLookup.GetEmployeeByRfidAsync(cardId).GetAwaiter().GetResult(); if (employee == null) return new ScanResult(false, "Card not registered in HRMS.", 0); var nowUtc = DateTime.UtcNow; var nowLocal = DateTime.Now; // Phase 2.5: DB-driven meal windows (MealSchedules table). Reject if no matching session. var siteIdForSession = !string.IsNullOrWhiteSpace(employee.LocationSiteId) ? employee.LocationSiteId.Trim() : _configService.GetSiteId(); var session = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); if (session == MealSession.None) return new ScanResult(false, "This scan is outside of valid meal timings.", 0); var sessionCode = (int)session; using var db = _dbFactory.CreateDbContext(); // Once-per-session-per-day rule: same card, same session, same local day is not allowed. var startOfTodayLocal = DateTime.Today; var startOfTomorrowLocal = startOfTodayLocal.AddDays(1); var startUtc = startOfTodayLocal.ToUniversalTime(); var endUtc = startOfTomorrowLocal.ToUniversalTime(); var alreadyScannedThisSessionToday = db.LunchOrderTransactions .Where(r => r.CardId == cardId) .Where(r => r.MealSessionCode == sessionCode) .Where(r => r.ScanTime >= startUtc && r.ScanTime < endUtc) .OrderByDescending(r => r.ScanTime) .FirstOrDefault(); if (alreadyScannedThisSessionToday != null) { var sessionName = GetMealSessionDisplayName(session); return new ScanResult(false, $"This card is already scanned for {sessionName} today.", 0); } var interval = _configService.GetScanInterval(); var timeoutSeconds = (int)Math.Max(1, interval.TotalSeconds); var windowStart = nowUtc.AddSeconds(-timeoutSeconds); // Safety debounce: short cooldown per card to prevent accidental double-tap. 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); } var fullName = string.Join(" ", new[] { employee.FirstName, employee.MiddleName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); var siteId = !string.IsNullOrWhiteSpace(employee.LocationSiteId) ? employee.LocationSiteId.Trim() : _configService.GetSiteId(); var record = new ScanRecord { CardId = cardId, ScanTime = nowUtc, IsSynced = false, SiteId = siteId, DeviceId = _configService.GetDeviceId(), IpAddress = GetLocalIpAddress(), MealSessionCode = sessionCode, ParentDocumentId = employee.ParentDocumentId ?? string.Empty, EmployeeId = employee.EmployeeId ?? string.Empty, UindSerial = employee.UindSerial ?? string.Empty, FunctionId = employee.FunctionId, DepartmentId = employee.DepartmentId, TagCreatedAtUtc = employee.TagCreatedAtUtc, TagCreatedBy = employee.TagCreatedBy ?? string.Empty, EmployeeName = string.IsNullOrEmpty(fullName) ? string.Empty : fullName, Department = employee.DepartmentTitle ?? string.Empty, DepartmentType = employee.DepartmentType ?? string.Empty }; db.LunchOrderTransactions.Add(record); db.SaveChanges(); return new ScanResult(true, "Order recorded successfully.", 0, employee, session); } private static string GetMealSessionDisplayName(MealSession session) { return session switch { MealSession.Breakfast => "Sehri", MealSession.Lunch => "Iftari", MealSession.Tea => "Tea", MealSession.Dinner => "Dinner", _ => "this meal" }; } 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); } public void UpdateLastScanMealInfo(string mealLabel, string mealItems, double totalPrice) { using var db = _dbFactory.CreateDbContext(); var last = db.LunchOrderTransactions .OrderByDescending(r => r.ScanTime) .FirstOrDefault(); if (last == null) return; last.MealLabel = mealLabel ?? string.Empty; last.MealItems = mealItems ?? string.Empty; last.TotalPrice = totalPrice; db.SaveChanges(); } 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; } } }