using System.Text.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Backup;
///
/// Exports/imports the configuration snapshot (SDD §10). Import restores into an empty instance,
/// remapping surrogate ids so foreign keys stay consistent regardless of the original ids.
///
public sealed class ExportService(MeterVaultDbContext db)
{
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = true,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
};
private readonly MeterVaultDbContext _db = db;
///
/// Strips encrypted connector secrets from an export. They are ciphertext, not plaintext, so
/// this is not a §6.4 requirement — but the export exists for portability (§9), and ciphertext
/// is bound to the originating instance's key ring, so it is useless anywhere it could be
/// restored and merely widens the blast radius if the key ring also leaks. Env-var references
/// survive: they name a variable and reveal nothing. Restoring means re-entering the secrets.
///
private static List RedactSecrets(List endpoints)
{
foreach (var endpoint in endpoints)
{
endpoint.Config = endpoint.Type == EndpointType.HomeAssistant
? (Ingestion.HaEndpointConfig.Parse(endpoint.Config) with { TokenEnc = null }).ToJson()
: (Ingestion.EndpointConfig.Parse(endpoint.Config) with { PasswordEnc = null }).ToJson();
}
return endpoints;
}
public async Task ExportJsonAsync(CancellationToken cancellationToken = default)
{
// Load without navigation includes so serialization is a clean, cycle-free tree.
var document = new ExportDocument
{
EnergyTypes = await _db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
CostCategories = await _db.CostCategories.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
Meters = await _db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
IngestionEndpoints = RedactSecrets(
await _db.IngestionEndpoints.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false)),
MeterSources = await _db.MeterSources.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
Tanks = await _db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
Tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
CostCategoryMembers = await _db.CostCategoryMembers.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
ManualCosts = await _db.ManualCosts.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
MeterEvents = await _db.MeterEvents.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
AppSettings = await _db.AppSettings.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
};
return JsonSerializer.Serialize(document, Json);
}
public async Task ImportJsonAsync(string json, CancellationToken cancellationToken = default)
{
var doc = JsonSerializer.Deserialize(json, Json)
?? throw new InvalidOperationException("Empty or invalid export document.");
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var typeMap = new Dictionary();
foreach (var type in doc.EnergyTypes)
{
var old = type.Id;
type.Id = 0;
_db.EnergyTypes.Add(type);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
typeMap[old] = type.Id;
}
var categoryMap = await InsertMappedAsync(doc.CostCategories, c => c.Id, (c, _) => c.Id = 0, cancellationToken).ConfigureAwait(false);
var meterMap = new Dictionary();
foreach (var meter in doc.Meters)
{
var old = meter.Id;
meter.Id = 0;
meter.EnergyTypeId = typeMap.GetValueOrDefault(meter.EnergyTypeId, meter.EnergyTypeId);
meter.EnergyType = null;
_db.Meters.Add(meter);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
meterMap[old] = meter.Id;
}
var endpointMap = await InsertMappedAsync(doc.IngestionEndpoints, e => e.Id, (e, _) => e.Id = 0, cancellationToken).ConfigureAwait(false);
foreach (var source in doc.MeterSources)
{
source.Id = 0;
source.MeterId = meterMap.GetValueOrDefault(source.MeterId, source.MeterId);
source.EndpointId = source.EndpointId is { } eid ? endpointMap.GetValueOrDefault(eid, eid) : null;
source.Meter = null;
source.Endpoint = null;
_db.MeterSources.Add(source);
}
foreach (var tank in doc.Tanks)
{
tank.Id = 0;
tank.MeterId = meterMap.GetValueOrDefault(tank.MeterId, tank.MeterId);
tank.Meter = null;
_db.Tanks.Add(tank);
}
foreach (var tariff in doc.Tariffs)
{
tariff.Id = 0;
tariff.ScopeId = tariff.ScopeType switch
{
TariffScope.Meter when tariff.ScopeId is { } id => meterMap.GetValueOrDefault(id, id),
TariffScope.EnergyType when tariff.ScopeId is { } id => typeMap.GetValueOrDefault((short)id, (short)id),
_ => tariff.ScopeId,
};
_db.Tariffs.Add(tariff);
}
foreach (var member in doc.CostCategoryMembers)
{
member.Id = 0;
member.CategoryId = categoryMap.GetValueOrDefault(member.CategoryId, member.CategoryId);
member.MeterId = member.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
member.EnergyTypeId = member.EnergyTypeId is { } tid ? typeMap.GetValueOrDefault(tid, tid) : null;
member.Category = null;
_db.CostCategoryMembers.Add(member);
}
foreach (var cost in doc.ManualCosts)
{
cost.Id = 0;
cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null;
cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
cost.ImportBatchId = null; // the original import batch isn't part of the export
_db.ManualCosts.Add(cost);
}
foreach (var meterEvent in doc.MeterEvents)
{
meterEvent.Id = 0;
meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId);
meterEvent.ImportBatchId = null;
_db.MeterEvents.Add(meterEvent);
}
foreach (var setting in doc.AppSettings)
{
if (!await _db.AppSettings.AnyAsync(s => s.Key == setting.Key, cancellationToken).ConfigureAwait(false))
{
_db.AppSettings.Add(setting);
}
}
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
}
private async Task> InsertMappedAsync(
List items, Func getId, Action clearId, CancellationToken cancellationToken)
where T : class
{
var map = new Dictionary();
foreach (var item in items)
{
var old = getId(item);
clearId(item, 0);
_db.Add(item);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
map[old] = getId(item);
}
return map;
}
}