TenantAtlas/app/Support/BackupQuality/BackupQualityResolver.php
2026-04-07 13:38:16 +02:00

475 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\BackupQuality;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\PolicyVersion;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class BackupQualityResolver
{
public function summarizeBackupSet(BackupSet $backupSet): BackupQualitySummary
{
$items = $backupSet->relationLoaded('items')
? $backupSet->items
: $backupSet->items()->get();
return $this->summarizeBackupItems(
$items,
max((int) ($backupSet->item_count ?? 0), $items->count()),
);
}
/**
* @param iterable<BackupItem> $items
*/
public function summarizeBackupItems(iterable $items, ?int $totalItems = null): BackupQualitySummary
{
$itemSummaries = Collection::make($items)
->map(fn (BackupItem $item): BackupQualitySummary => $this->forBackupItem($item))
->values();
$resolvedTotalItems = max($itemSummaries->count(), (int) ($totalItems ?? 0));
$metadataOnlyCount = $itemSummaries->where('metadataOnlyCount', '>', 0)->count();
$assignmentIssueCount = $itemSummaries->where('assignmentIssueCount', '>', 0)->count();
$orphanedAssignmentCount = $itemSummaries->where('orphanedAssignmentCount', '>', 0)->count();
$integrityWarningCount = $itemSummaries->where('integrityWarningCount', '>', 0)->count();
$unknownQualityCount = $itemSummaries->where('unknownQualityCount', '>', 0)->count();
$degradedItemCount = $itemSummaries->filter(
fn (BackupQualitySummary $summary): bool => $summary->hasDegradations()
)->count();
$degradationFamilies = $this->orderedFamilies(
$itemSummaries
->flatMap(fn (BackupQualitySummary $summary): array => $summary->degradationFamilies)
->all(),
);
$qualityHighlights = $this->setHighlights(
totalItems: $resolvedTotalItems,
degradedItemCount: $degradedItemCount,
metadataOnlyCount: $metadataOnlyCount,
assignmentIssueCount: $assignmentIssueCount,
orphanedAssignmentCount: $orphanedAssignmentCount,
integrityWarningCount: $integrityWarningCount,
unknownQualityCount: $unknownQualityCount,
);
$compactSummary = $qualityHighlights === []
? $this->defaultSetCompactSummary($resolvedTotalItems)
: implode(' • ', $qualityHighlights);
$summaryMessage = match (true) {
$resolvedTotalItems === 0 => 'No backup items were captured in this set.',
$degradedItemCount === 0 => sprintf(
'No degradations were detected across %d captured item%s.',
$resolvedTotalItems,
$resolvedTotalItems === 1 ? '' : 's',
),
default => sprintf(
'%d of %d captured item%s show degraded input quality.',
$degradedItemCount,
$resolvedTotalItems,
$resolvedTotalItems === 1 ? '' : 's',
),
};
$nextAction = match (true) {
$resolvedTotalItems === 0 => 'Create or refresh a backup set before starting a restore review.',
$degradedItemCount > 0 => 'Open the backup set detail and inspect degraded items before continuing into restore.',
default => 'Open the backup set detail to verify item-level context before relying on it for restore work.',
};
return new BackupQualitySummary(
kind: 'backup_set',
snapshotMode: $this->aggregateSnapshotMode($resolvedTotalItems, $metadataOnlyCount, $unknownQualityCount),
totalItems: $resolvedTotalItems,
degradedItemCount: $degradedItemCount,
metadataOnlyCount: $metadataOnlyCount,
assignmentIssueCount: $assignmentIssueCount,
orphanedAssignmentCount: $orphanedAssignmentCount,
integrityWarningCount: $integrityWarningCount,
unknownQualityCount: $unknownQualityCount,
hasAssignmentIssues: $assignmentIssueCount > 0,
hasOrphanedAssignments: $orphanedAssignmentCount > 0,
assignmentCaptureReason: null,
integrityWarning: null,
degradationFamilies: $degradationFamilies,
qualityHighlights: $qualityHighlights,
compactSummary: $compactSummary,
summaryMessage: $summaryMessage,
nextAction: $nextAction,
positiveClaimBoundary: $this->positiveClaimBoundary(),
);
}
public function forBackupItem(BackupItem $backupItem): BackupQualitySummary
{
$snapshotMode = $this->resolveSnapshotMode(
source: $backupItem->snapshotSource(),
warnings: $backupItem->warningMessages(),
hasCapturedPayload: $backupItem->hasCapturedPayload(),
);
$assignmentCaptureReason = $backupItem->assignmentCaptureReason();
$integrityWarning = $backupItem->integrityWarning();
$hasAssignmentIssues = $backupItem->assignmentsFetchFailed();
$hasOrphanedAssignments = $backupItem->hasOrphanedAssignments();
$degradationFamilies = $this->singleRecordFamilies(
snapshotMode: $snapshotMode,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
integrityWarning: $integrityWarning,
);
$qualityHighlights = $this->singleRecordHighlights(
snapshotMode: $snapshotMode,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
integrityWarning: $integrityWarning,
assignmentCaptureReason: $assignmentCaptureReason,
);
return new BackupQualitySummary(
kind: 'backup_item',
snapshotMode: $snapshotMode,
totalItems: 1,
degradedItemCount: $degradationFamilies === [] ? 0 : 1,
metadataOnlyCount: $snapshotMode === 'metadata_only' ? 1 : 0,
assignmentIssueCount: $hasAssignmentIssues ? 1 : 0,
orphanedAssignmentCount: $hasOrphanedAssignments ? 1 : 0,
integrityWarningCount: $integrityWarning !== null ? 1 : 0,
unknownQualityCount: $degradationFamilies === ['unknown_quality'] ? 1 : 0,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
assignmentCaptureReason: $assignmentCaptureReason,
integrityWarning: $integrityWarning,
degradationFamilies: $degradationFamilies,
qualityHighlights: $qualityHighlights,
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
nextAction: $degradationFamilies === []
? 'Open the linked detail if you need deeper restore context.'
: 'Inspect the linked detail before relying on this backup item for restore.',
positiveClaimBoundary: $this->positiveClaimBoundary(),
);
}
public function forPolicyVersion(PolicyVersion $policyVersion): BackupQualitySummary
{
$snapshotMode = $this->resolveSnapshotMode(
source: $policyVersion->snapshotSource(),
warnings: $policyVersion->warningMessages(),
hasCapturedPayload: $policyVersion->hasCapturedPayload(),
);
$integrityWarning = $policyVersion->integrityWarning();
$hasAssignmentIssues = $policyVersion->assignmentsFetchFailed();
$hasOrphanedAssignments = $policyVersion->hasOrphanedAssignments();
$degradationFamilies = $this->singleRecordFamilies(
snapshotMode: $snapshotMode,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
integrityWarning: $integrityWarning,
);
$qualityHighlights = $this->singleRecordHighlights(
snapshotMode: $snapshotMode,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
integrityWarning: $integrityWarning,
);
return new BackupQualitySummary(
kind: 'policy_version',
snapshotMode: $snapshotMode,
totalItems: 1,
degradedItemCount: $degradationFamilies === [] ? 0 : 1,
metadataOnlyCount: $snapshotMode === 'metadata_only' ? 1 : 0,
assignmentIssueCount: $hasAssignmentIssues ? 1 : 0,
orphanedAssignmentCount: $hasOrphanedAssignments ? 1 : 0,
integrityWarningCount: $integrityWarning !== null ? 1 : 0,
unknownQualityCount: $degradationFamilies === ['unknown_quality'] ? 1 : 0,
hasAssignmentIssues: $hasAssignmentIssues,
hasOrphanedAssignments: $hasOrphanedAssignments,
assignmentCaptureReason: null,
integrityWarning: $integrityWarning,
degradationFamilies: $degradationFamilies,
qualityHighlights: $qualityHighlights,
compactSummary: $this->compactSummaryFromHighlights($qualityHighlights, $snapshotMode),
summaryMessage: $this->singleRecordSummaryMessage($qualityHighlights, $snapshotMode),
nextAction: $degradationFamilies === []
? 'Open the version detail if you need raw settings or diff context.'
: 'Prefer a stronger version or inspect the version detail before restore.',
positiveClaimBoundary: $this->positiveClaimBoundary(),
);
}
/**
* @param list<string> $warnings
*/
private function resolveSnapshotMode(?string $source, array $warnings, bool $hasCapturedPayload): string
{
if ($source === 'metadata_only' || $this->warningsIndicateMetadataOnly($warnings)) {
return 'metadata_only';
}
if ($hasCapturedPayload) {
return 'full';
}
return 'unknown';
}
/**
* @param list<string> $warnings
*/
private function warningsIndicateMetadataOnly(array $warnings): bool
{
return Collection::make($warnings)
->contains(function (mixed $warning): bool {
if (! is_string($warning)) {
return false;
}
$normalized = Str::lower($warning);
return str_contains($normalized, 'metadata')
&& (
str_contains($normalized, 'only')
|| str_contains($normalized, 'fallback')
);
});
}
/**
* @return list<string>
*/
private function singleRecordFamilies(
string $snapshotMode,
bool $hasAssignmentIssues,
bool $hasOrphanedAssignments,
?string $integrityWarning,
): array {
$families = [];
if ($snapshotMode === 'metadata_only') {
$families[] = 'metadata_only';
}
if ($hasAssignmentIssues) {
$families[] = 'assignment_capture_issue';
}
if ($hasOrphanedAssignments) {
$families[] = 'orphaned_assignments';
}
if ($integrityWarning !== null) {
$families[] = 'integrity_warning';
}
if ($families === [] && $snapshotMode === 'unknown') {
$families[] = 'unknown_quality';
}
return $this->orderedFamilies($families);
}
/**
* @return list<string>
*/
private function singleRecordHighlights(
string $snapshotMode,
bool $hasAssignmentIssues,
bool $hasOrphanedAssignments,
?string $integrityWarning,
?string $assignmentCaptureReason = null,
): array {
$highlights = [];
if ($snapshotMode === 'metadata_only') {
$highlights[] = 'Metadata only';
}
if ($hasAssignmentIssues) {
$highlights[] = 'Assignment fetch failed';
} elseif ($assignmentCaptureReason === 'separate_role_assignments') {
$highlights[] = 'Assignments captured separately';
}
if ($hasOrphanedAssignments) {
$highlights[] = 'Orphaned assignments';
}
if ($integrityWarning !== null) {
$highlights[] = 'Integrity warning';
}
if ($snapshotMode === 'unknown' && $highlights === []) {
$highlights[] = 'Unknown quality';
}
return array_values(array_unique($highlights));
}
private function compactSummaryFromHighlights(array $qualityHighlights, string $snapshotMode): string
{
if ($qualityHighlights !== []) {
return implode(' • ', $qualityHighlights);
}
return match ($snapshotMode) {
'full' => 'Full payload',
'unknown' => 'Unknown quality',
default => 'No degradations detected',
};
}
private function singleRecordSummaryMessage(array $qualityHighlights, string $snapshotMode): string
{
if ($qualityHighlights === []) {
return match ($snapshotMode) {
'full' => 'No degradations were detected from the captured snapshot and assignment metadata.',
'unknown' => 'Quality is unknown because this record lacks enough completeness metadata to justify a stronger claim.',
default => 'No degradations were detected.',
};
}
return implode(' • ', $qualityHighlights).'.';
}
private function aggregateSnapshotMode(int $totalItems, int $metadataOnlyCount, int $unknownQualityCount): string
{
if ($totalItems === 0) {
return 'unknown';
}
if ($metadataOnlyCount === $totalItems) {
return 'metadata_only';
}
if ($metadataOnlyCount === 0 && $unknownQualityCount === 0) {
return 'full';
}
return 'unknown';
}
/**
* @return list<string>
*/
private function orderedFamilies(array $families): array
{
$weights = [
'metadata_only' => 10,
'assignment_capture_issue' => 20,
'orphaned_assignments' => 30,
'integrity_warning' => 40,
'unknown_quality' => 50,
];
$families = array_values(array_unique(array_filter(
$families,
static fn (mixed $family): bool => is_string($family) && $family !== '',
)));
usort($families, static function (string $left, string $right) use ($weights): int {
return ($weights[$left] ?? 999) <=> ($weights[$right] ?? 999);
});
return $families;
}
/**
* @return list<string>
*/
private function setHighlights(
int $totalItems,
int $degradedItemCount,
int $metadataOnlyCount,
int $assignmentIssueCount,
int $orphanedAssignmentCount,
int $integrityWarningCount,
int $unknownQualityCount,
): array {
if ($totalItems === 0) {
return [];
}
$highlights = [];
if ($degradedItemCount > 0) {
$highlights[] = sprintf(
'%d degraded item%s',
$degradedItemCount,
$degradedItemCount === 1 ? '' : 's',
);
}
if ($metadataOnlyCount > 0) {
$highlights[] = sprintf(
'%d metadata-only',
$metadataOnlyCount,
);
}
if ($assignmentIssueCount > 0) {
$highlights[] = sprintf(
'%d assignment issue%s',
$assignmentIssueCount,
$assignmentIssueCount === 1 ? '' : 's',
);
}
if ($orphanedAssignmentCount > 0) {
$highlights[] = sprintf(
'%d orphaned assignment%s',
$orphanedAssignmentCount,
$orphanedAssignmentCount === 1 ? '' : 's',
);
}
if ($integrityWarningCount > 0) {
$highlights[] = sprintf(
'%d integrity warning%s',
$integrityWarningCount,
$integrityWarningCount === 1 ? '' : 's',
);
}
if ($unknownQualityCount > 0) {
$highlights[] = sprintf(
'%d unknown quality',
$unknownQualityCount,
);
}
return $highlights;
}
private function defaultSetCompactSummary(int $totalItems): string
{
if ($totalItems === 0) {
return 'No items captured';
}
return sprintf(
'No degradations detected across %d item%s',
$totalItems,
$totalItems === 1 ? '' : 's',
);
}
private function positiveClaimBoundary(): string
{
return 'Input quality signals do not prove safe restore, restore readiness, or tenant-wide recoverability.';
}
}