using System.Collections.ObjectModel;
namespace LehrerApp.Core.Importing;
///
/// Stabile, typsichere Kennung eines Importformats. Werte werden normalisiert, damit
/// Groß-/Kleinschreibung oder versehentliche Leerzeichen nicht zu Tippfehlern führen.
///
public readonly record struct ImportFormatId
{
public string Value { get; }
public ImportFormatId(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
Value = value.Trim().ToLowerInvariant();
}
public override string ToString() => Value ?? "";
}
/// Eine vollständig in den Speicher geladene Importdatei.
public sealed class ImportFile
{
public string FileName { get; }
public ReadOnlyMemory Content { get; }
public string Extension => Path.GetExtension(FileName).ToLowerInvariant();
public ImportFile(string fileName, ReadOnlyMemory content)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
FileName = Path.GetFileName(fileName);
Content = content;
}
}
public enum ImportMessageSeverity { Information, Warning, Error }
public sealed record ImportMessage(
ImportMessageSeverity Severity,
string Code,
string Message,
string? SourceReference = null);
///
/// Ergebnis einer schnellen Formaterkennung. 0 bedeutet „kein Treffer“, 100 einen
/// eindeutigen Treffer. Die eigentliche vollständige Prüfung erfolgt erst beim Parsen.
///
public sealed record ImportDetection
{
public int Confidence { get; }
public string? Reason { get; }
public bool IsMatch => Confidence > 0;
public ImportDetection(int confidence, string? reason = null)
{
if (confidence is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(confidence));
Confidence = confidence;
Reason = reason;
}
public static ImportDetection NoMatch(string? reason = null) => new(0, reason);
public static ImportDetection Match(int confidence, string? reason = null) =>
new(confidence, reason);
}
public sealed class ImportParseResult
{
public IReadOnlyList Items { get; }
public IReadOnlyList Messages { get; }
public bool IsValid => Messages.All(message => message.Severity != ImportMessageSeverity.Error);
public ImportParseResult(IEnumerable items, IEnumerable? messages = null)
{
ArgumentNullException.ThrowIfNull(items);
Items = ReadOnly(items);
Messages = ReadOnly(messages ?? []);
}
private static IReadOnlyList ReadOnly(IEnumerable values) =>
new ReadOnlyCollection(values.ToList());
}
///
/// Ein registrierbarer, UI-unabhängiger Handler für genau ein Dateiformat und ein
/// normalisiertes fachliches Importmodell.
///
public interface IImportHandler
{
ImportFormatId FormatId { get; }
string DisplayName { get; }
IReadOnlyCollection SupportedExtensions { get; }
ValueTask DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default);
ValueTask> ParseAsync(
ImportFile file,
CancellationToken cancellationToken = default);
}
public sealed record DetectedImportHandler(
IImportHandler Handler,
ImportDetection Detection);
public abstract class ImportFormatException(string message) : Exception(message);
public sealed class UnsupportedImportFormatException(string fileName)
: ImportFormatException($"Für die Datei „{fileName}“ wurde kein passendes Importformat gefunden.");
public sealed class AmbiguousImportFormatException(string fileName, IEnumerable formats)
: ImportFormatException(
$"Die Datei „{fileName}“ passt zu mehreren Importformaten: {string.Join(", ", formats)}.");
///
/// Verwaltet Handler eines fachlichen Importziels und wählt anhand des Dateiinhalts
/// den besten Treffer. Die Dateiendung dient bei gleicher Erkennungsqualität als Hinweis.
///
public sealed class ImportHandlerCatalog
{
private readonly IReadOnlyList> _handlers;
public ImportHandlerCatalog(IEnumerable> handlers)
{
ArgumentNullException.ThrowIfNull(handlers);
var materialized = handlers.ToList();
var duplicate = materialized
.GroupBy(handler => handler.FormatId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicate is not null)
throw new ArgumentException(
$"Das Importformat „{duplicate.Key}“ wurde mehrfach registriert.", nameof(handlers));
_handlers = new ReadOnlyCollection>(materialized);
}
public IReadOnlyList> Handlers => _handlers;
public IReadOnlyList SupportedExtensions => _handlers
.SelectMany(handler => handler.SupportedExtensions)
.Select(NormalizeExtension)
.Where(extension => extension.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(extension => extension, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
public async ValueTask> DetectAsync(
ImportFile file,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(file);
var matches = new List();
foreach (var handler in _handlers)
{
cancellationToken.ThrowIfCancellationRequested();
var detection = await handler.DetectAsync(file, cancellationToken).ConfigureAwait(false);
if (detection.IsMatch)
matches.Add(new Candidate(handler, detection, SupportsExtension(handler, file.Extension)));
}
if (matches.Count == 0)
throw new UnsupportedImportFormatException(file.FileName);
var highestConfidence = matches.Max(match => match.Detection.Confidence);
var best = matches.Where(match => match.Detection.Confidence == highestConfidence).ToList();
if (best.Count > 1 && best.Any(match => match.ExtensionMatches))
best = best.Where(match => match.ExtensionMatches).ToList();
if (best.Count != 1)
throw new AmbiguousImportFormatException(
file.FileName,
best.Select(match => match.Handler.DisplayName));
return new DetectedImportHandler(best[0].Handler, best[0].Detection);
}
private static bool SupportsExtension(IImportHandler handler, string extension) =>
handler.SupportedExtensions
.Select(NormalizeExtension)
.Contains(extension, StringComparer.OrdinalIgnoreCase);
private static string NormalizeExtension(string extension)
{
if (string.IsNullOrWhiteSpace(extension)) return "";
var trimmed = extension.Trim().ToLowerInvariant();
return trimmed.StartsWith('.') ? trimmed : $".{trimmed}";
}
private sealed record Candidate(
IImportHandler Handler,
ImportDetection Detection,
bool ExtensionMatches);
}
public sealed record ImportConflictOption(string Id, string Label, string? Description = null);
public sealed record ImportConflict(
string Id,
string Title,
string Description,
string ImportedValue,
string? ExistingValue,
IReadOnlyList Options,
string DefaultOptionId,
string? SourceReference = null);
/// Vom UI getroffene, für den Core rein datenförmige Konfliktentscheidung.
public sealed record ImportDecision(string ConflictId, string SelectedOptionId);