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;
}
}
}