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,
$"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.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 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()
};
db.ScanRecords.Add(record);
db.SaveChanges();
return new ScanResult(true, "Order 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 GetTotalScanCountAsync(CancellationToken cancellationToken = default)
{
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await db.ScanRecords.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.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);
}
}