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; private readonly IMenuLookupService _menuLookup; public RfidService( IDbContextFactory dbFactory, IConfigService configService, IEmployeeLookupService employeeLookup, IMealSessionResolver mealSessionResolver, IMenuLookupService menuLookup) { _dbFactory = dbFactory; _configService = configService; _employeeLookup = employeeLookup; _mealSessionResolver = mealSessionResolver; _menuLookup = menuLookup; } 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; var resolvedSession = _mealSessionResolver.GetCurrentSession(nowLocal, siteIdForSession); if (resolvedSession == null || resolvedSession.Session == MealSession.None) return new ScanResult(false, "This scan is outside of valid meal timings.", 0); var session = resolvedSession.Session; 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); //} if (alreadyScannedThisSessionToday != null) { //var sessionName = session.ToString(); var sessionName = resolvedSession.MealName; 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(); // Resolve menu items for this scan (store on record so order history shows actual items) //var mealLabel = GetMealSessionDisplayName(session); //var mealLabel = session.ToString(); var mealLabel = resolvedSession.MealName; var mealItemsDisplay = string.Empty; double totalPrice = 0; try { if (int.TryParse(new string(siteIdForSession.Where(char.IsDigit).ToArray()), out var siteNumeric) && siteNumeric > 0) { var menuItems = _menuLookup .GetMenuItemsForSiteAndDateAsync(siteNumeric, nowLocal.Date) .GetAwaiter() .GetResult(); //var matching = menuItems // .Where(i => string.Equals(i.MealName, mealLabel, StringComparison.OrdinalIgnoreCase)) // .ToList(); var matching = menuItems.ToList(); var names = matching .Select(i => i.ItemName) .Where(n => !string.IsNullOrWhiteSpace(n)) .Distinct() .ToList(); if (names.Count > 0) { mealItemsDisplay = string.Join(" + ", names); totalPrice = matching.Sum(i => (double)i.Price); } } } catch (Exception ex) { Logger.Log(ex, "RfidService.ProcessScanDetailed menu lookup"); } 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, MealLabel = mealLabel, MealItems = mealItemsDisplay, TotalPrice = totalPrice }; 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; } } }