using System.Text.Json;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
///
/// Table Storage implementation of .
/// PartitionKey = tenantId (lowercased). RowKey encodes the dedup subject:
/// - hardware rejection: "{manufacturer-lower}|{model-lower}" (trimmed)
/// - TPM PSS unsupported: "tpmpss|{serial-lower}" (trimmed; the literal "tpmpss" prefix
/// cannot collide with a hardware key because no real manufacturer is named "tpmpss")
/// - F3 rule regression: "ruleregression|{ruleId-lower}" (trimmed; payload-carrying rows
/// that are refreshed while the episode is active and deleted on re-arm)
/// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists.
///
public class TableHardwareRejectionNotificationTracker : IHardwareRejectionNotificationTracker
{
private readonly TableClient _table;
private readonly ILogger _logger;
public TableHardwareRejectionNotificationTracker(
TableStorageService storage,
ILogger logger)
{
_logger = logger;
_table = storage.GetTableClient(Constants.TableNames.HardwareRejectionNotificationTracker);
}
public async Task TryRegisterFirstNotificationAsync(string tenantId, string manufacturer, string model)
{
if (string.IsNullOrWhiteSpace(tenantId)
|| string.IsNullOrWhiteSpace(manufacturer)
|| string.IsNullOrWhiteSpace(model))
{
return false;
}
var partitionKey = tenantId.ToLowerInvariant();
var rowKey = BuildRowKey(manufacturer, model);
var entity = new TableEntity(partitionKey, rowKey)
{
["TenantId"] = tenantId,
["Manufacturer"] = manufacturer,
["Model"] = model,
["FirstNotifiedAt"] = DateTime.UtcNow
};
try
{
await _table.AddEntityAsync(entity);
_logger.LogInformation(
"HardwareRejection tracker registered: tenant={TenantId} mfr={Manufacturer} model={Model}",
tenantId, manufacturer, model);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Already notified for this (tenant, manufacturer, model) — no second bell.
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"HardwareRejection tracker failed: tenant={TenantId} mfr={Manufacturer} model={Model}",
tenantId, manufacturer, model);
// On unexpected failure, return false so we do not double-fire if the row was actually written.
return false;
}
}
public async Task TryRegisterFirstTpmPssNotificationAsync(string tenantId, string serialNumber)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(serialNumber))
return false;
var partitionKey = tenantId.ToLowerInvariant();
var rowKey = BuildTpmPssRowKey(serialNumber);
var entity = new TableEntity(partitionKey, rowKey)
{
["TenantId"] = tenantId,
["SerialNumber"] = serialNumber,
["FirstNotifiedAt"] = DateTime.UtcNow
};
try
{
await _table.AddEntityAsync(entity);
_logger.LogInformation(
"TpmPssUnsupported tracker registered: tenant={TenantId} sn={SerialNumber}",
tenantId, serialNumber);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Already notified for this (tenant, serial) — no second bell.
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"TpmPssUnsupported tracker failed: tenant={TenantId} sn={SerialNumber}",
tenantId, serialNumber);
// On unexpected failure, return false so we do not double-fire if the row was actually written.
return false;
}
}
public async Task TryRegisterRuleRegressionAsync(string tenantId, RuleRegressionAlert alert)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(alert?.RuleId))
return false;
try
{
await _table.AddEntityAsync(BuildRuleRegressionEntity(tenantId, alert!));
_logger.LogInformation(
"RuleRegression tracker registered: tenant={TenantId} rule={RuleId}", tenantId, alert!.RuleId);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Episode already active — one bell per episode.
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"RuleRegression tracker failed: tenant={TenantId} rule={RuleId}", tenantId, alert!.RuleId);
// Fail closed so we do not double-fire if the row was actually written.
return false;
}
}
public async Task RefreshRuleRegressionAsync(string tenantId, RuleRegressionAlert alert)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(alert?.RuleId))
return;
try
{
// Replace-mode upsert with the FULL payload incl. the caller-carried
// FirstNotifiedAt — the retention sweep re-arms on the ORIGINAL age, so a
// refresh must never rejuvenate the row.
await _table.UpsertEntityAsync(BuildRuleRegressionEntity(tenantId, alert!), TableUpdateMode.Replace);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"RuleRegression tracker refresh failed: tenant={TenantId} rule={RuleId} (stale numbers remain)",
tenantId, alert!.RuleId);
}
}
public async Task DeleteRuleRegressionAsync(string tenantId, string ruleId)
{
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(ruleId))
return;
try
{
await _table.DeleteEntityAsync(tenantId.ToLowerInvariant(), BuildRuleRegressionRowKey(ruleId));
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Already gone (retention sweep or concurrent pass) — idempotent.
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"RuleRegression tracker delete failed: tenant={TenantId} rule={RuleId} (episode stays until retention)",
tenantId, ruleId);
}
}
public async Task> GetRuleRegressionsAsync(string tenantId)
{
var results = new List();
if (string.IsNullOrWhiteSpace(tenantId)) return results;
try
{
var partitionKey = tenantId.ToLowerInvariant();
// '}' (0x7D) sorts directly after '|' (0x7C) — prefix range over "ruleregression|…".
var filter = $"PartitionKey eq '{partitionKey}' and RowKey ge '{RuleRegressionRowKeyPrefix}' and RowKey lt 'ruleregression}}'";
await foreach (var entity in _table.QueryAsync(filter: filter))
{
results.Add(MapToRuleRegressionAlert(entity));
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "RuleRegression tracker list failed for tenant {TenantId}", tenantId);
}
return results;
}
public async Task DeleteOlderThanAsync(DateTime cutoffUtc)
{
try
{
// FirstNotifiedAt == the row's first registration (rule-regression refreshes carry
// it forward unchanged). Pruning here resets the dedup for EVERY key space in this
// table: a hardware model rejected again after the cutoff rings once more, so does
// a TPM-PSS device that reports again ("tpmpss|{serial}" rows), and a rule
// regression still active after the window fires a fresh bell — a month-old
// still-burning regression is worth a reminder (spec §F3 retention re-arm).
var filter = $"FirstNotifiedAt lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'";
var query = _table.QueryAsync(filter: filter, select: new[] { "PartitionKey", "RowKey" });
int deleted = 0;
await foreach (var entity in query)
{
try
{
await _table.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
deleted++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete hardware-rejection tracker row {PK}/{RK}", entity.PartitionKey, entity.RowKey);
}
}
if (deleted > 0)
_logger.LogInformation("Deleted {Count} hardware-rejection tracker rows older than {Cutoff:yyyy-MM-dd}", deleted, cutoffUtc);
return deleted;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete old hardware-rejection tracker rows");
return 0;
}
}
internal static string BuildRowKey(string manufacturer, string model)
{
var mfr = (manufacturer ?? string.Empty).Trim().ToLowerInvariant();
var mdl = (model ?? string.Empty).Trim().ToLowerInvariant();
return $"{mfr}|{mdl}";
}
internal static string BuildTpmPssRowKey(string serialNumber)
{
var sn = (serialNumber ?? string.Empty).Trim().ToLowerInvariant();
return $"tpmpss|{sn}";
}
internal const string RuleRegressionRowKeyPrefix = "ruleregression|";
internal static string BuildRuleRegressionRowKey(string ruleId)
{
var id = (ruleId ?? string.Empty).Trim().ToLowerInvariant();
return $"{RuleRegressionRowKeyPrefix}{id}";
}
// internal static: entity builder + mapper are pinned by round-trip unit tests
// (table-storage-serialization rule — every property must survive Store→Map).
internal static TableEntity BuildRuleRegressionEntity(string tenantId, RuleRegressionAlert alert)
{
var entity = new TableEntity(tenantId.ToLowerInvariant(), BuildRuleRegressionRowKey(alert.RuleId))
{
["TenantId"] = tenantId,
["RuleId"] = alert.RuleId,
["RuleTitle"] = alert.RuleTitle ?? string.Empty,
["WindowFireCount"] = alert.WindowFireCount,
["WindowSessionCount"] = alert.WindowSessionCount,
["BaselineFireCount"] = alert.BaselineFireCount,
["BaselineSessionCount"] = alert.BaselineSessionCount,
["WindowRatePct"] = alert.WindowRatePct,
["BaselineRatePct"] = alert.BaselineRatePct,
["WindowStartDate"] = alert.WindowStartDate ?? string.Empty,
["WindowEndDate"] = alert.WindowEndDate ?? string.Empty,
["FirstNotifiedAt"] = alert.FirstNotifiedAt,
["LastEvaluatedAt"] = alert.LastEvaluatedAt,
};
// Tri-states: absent column = "no claim" — a zero-baseline alert has no finite lift,
// and a missing dimension means "no clear concentration", never a guessed one.
if (alert.Lift.HasValue)
entity["Lift"] = alert.Lift.Value;
if (alert.Dimension != null)
entity["DimensionJson"] = JsonSerializer.Serialize(alert.Dimension);
return entity;
}
internal static RuleRegressionAlert MapToRuleRegressionAlert(TableEntity entity)
{
RuleRegressionDimension? dimension = null;
var dimensionJson = entity.GetString("DimensionJson");
if (!string.IsNullOrEmpty(dimensionJson))
{
try { dimension = JsonSerializer.Deserialize(dimensionJson!); }
catch (JsonException) { dimension = null; } // corrupt column — degrade to "no claim"
}
return new RuleRegressionAlert
{
TenantId = entity.GetString("TenantId") ?? entity.PartitionKey,
RuleId = entity.GetString("RuleId") ?? entity.RowKey.Substring(RuleRegressionRowKeyPrefix.Length),
RuleTitle = entity.GetString("RuleTitle") ?? string.Empty,
WindowFireCount = entity.GetInt32("WindowFireCount") ?? 0,
WindowSessionCount = entity.GetInt32("WindowSessionCount") ?? 0,
BaselineFireCount = entity.GetInt32("BaselineFireCount") ?? 0,
BaselineSessionCount = entity.GetInt32("BaselineSessionCount") ?? 0,
WindowRatePct = entity.GetDouble("WindowRatePct") ?? 0,
BaselineRatePct = entity.GetDouble("BaselineRatePct") ?? 0,
Lift = entity.GetDouble("Lift"),
WindowStartDate = entity.GetString("WindowStartDate") ?? string.Empty,
WindowEndDate = entity.GetString("WindowEndDate") ?? string.Empty,
Dimension = dimension,
FirstNotifiedAt = entity.GetDateTimeOffset("FirstNotifiedAt")?.UtcDateTime ?? DateTime.MinValue,
LastEvaluatedAt = entity.GetDateTimeOffset("LastEvaluatedAt")?.UtcDateTime ?? DateTime.MinValue,
};
}
}
}