TenantAtlas/app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php
2026-03-11 00:05:33 +01:00

320 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Baselines\SnapshotRendering;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Throwable;
final class BaselineSnapshotPresenter
{
public function __construct(
private readonly SnapshotTypeRendererRegistry $registry,
) {}
public function present(BaselineSnapshot $snapshot): RenderedSnapshot
{
$snapshot->loadMissing(['baselineProfile', 'items']);
$summary = is_array($snapshot->summary_jsonb) ? $snapshot->summary_jsonb : [];
$items = $snapshot->items instanceof EloquentCollection
? $snapshot->items->sortBy([
['policy_type', 'asc'],
['id', 'asc'],
])->values()
: collect();
$groups = $items
->groupBy(static fn (BaselineSnapshotItem $item): string => (string) $item->policy_type)
->map(fn (Collection $groupItems, string $policyType): RenderedSnapshotGroup => $this->presentGroup($policyType, $groupItems))
->sortBy(static fn (RenderedSnapshotGroup $group): string => mb_strtolower($group->label))
->values()
->all();
$summaryRows = array_map(
static fn (RenderedSnapshotGroup $group): array => [
'policyType' => $group->policyType,
'label' => $group->label,
'itemCount' => $group->itemCount,
'fidelity' => $group->fidelity->value,
'gapCount' => $group->gapSummary->count,
'capturedAt' => $group->capturedAt,
'coverageHint' => $group->coverageHint,
],
$groups,
);
$overallGapCount = $this->summaryGapCount($summary);
$overallFidelity = FidelityState::fromSummary($summary, $items->isNotEmpty());
return new RenderedSnapshot(
snapshotId: (int) $snapshot->getKey(),
baselineProfileName: $snapshot->baselineProfile?->name,
capturedAt: $snapshot->captured_at?->toIso8601String(),
snapshotIdentityHash: is_string($snapshot->snapshot_identity_hash) && trim($snapshot->snapshot_identity_hash) !== ''
? trim($snapshot->snapshot_identity_hash)
: null,
stateLabel: $overallGapCount > 0 ? 'Captured with gaps' : 'Complete',
fidelitySummary: $this->fidelitySummary($summary),
overallFidelity: $overallFidelity,
overallGapCount: $overallGapCount,
summaryRows: $summaryRows,
groups: $groups,
technicalDetail: [
'defaultCollapsed' => true,
'summaryPayload' => $summary,
'groupPayloads' => array_map(
static fn (RenderedSnapshotGroup $group): array => [
'label' => $group->label,
'renderingError' => $group->renderingError,
'payload' => $group->technicalPayload,
],
$groups,
),
],
hasItems: $items->isNotEmpty(),
);
}
/**
* @param list<array<string, mixed>> $relatedContext
*/
public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relatedContext = []): EnterpriseDetailPageData
{
$rendered = $this->present($snapshot);
$factory = new EnterpriseDetailSectionFactory;
$stateBadge = $factory->statusBadge(
$rendered->stateLabel,
$rendered->overallGapCount > 0 ? 'warning' : 'success',
);
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
$fidelityBadge = $factory->statusBadge(
$fidelitySpec->label,
$fidelitySpec->color,
$fidelitySpec->icon,
$fidelitySpec->iconColor,
);
$capturedItemCount = array_sum(array_map(
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
$rendered->summaryRows,
));
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
->header(new SummaryHeaderData(
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
subtitle: 'Snapshot #'.$rendered->snapshotId,
statusBadges: [$stateBadge, $fidelityBadge],
keyFacts: [
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
$factory->keyFact('Captured items', $capturedItemCount),
],
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
))
->addSection(
$factory->viewSection(
id: 'coverage_summary',
kind: 'current_status',
title: 'Coverage summary',
view: 'filament.infolists.entries.baseline-snapshot-summary-table',
viewData: ['rows' => $rendered->summaryRows],
emptyState: $factory->emptyState('No captured policy types are available in this snapshot.'),
),
$factory->viewSection(
id: 'related_context',
kind: 'related_context',
title: 'Related context',
view: 'filament.infolists.entries.related-context',
viewData: ['entries' => $relatedContext],
emptyState: $factory->emptyState('No related context is available for this record.'),
),
$factory->viewSection(
id: 'captured_policy_types',
kind: 'domain_detail',
title: 'Captured policy types',
view: 'filament.infolists.entries.baseline-snapshot-groups',
viewData: ['groups' => array_map(
static fn (RenderedSnapshotGroup $group): array => $group->toArray(),
$rendered->groups,
)],
emptyState: $factory->emptyState('No snapshot items were captured for this baseline snapshot.'),
),
)
->addSupportingCard(
$factory->supportingFactsCard(
kind: 'status',
title: 'Snapshot status',
items: [
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
],
),
$factory->supportingFactsCard(
kind: 'timestamps',
title: 'Capture timing',
items: [
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
],
),
)
->addTechnicalSection(
$factory->technicalDetail(
title: 'Technical detail',
entries: [
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
],
description: 'Technical payloads are secondary on purpose. Use them for debugging capture fidelity and renderer fallbacks.',
view: 'filament.infolists.entries.baseline-snapshot-technical-detail',
viewData: ['technical' => $rendered->technicalDetail],
),
)
->build();
}
/**
* @param Collection<int, BaselineSnapshotItem> $items
*/
private function presentGroup(string $policyType, Collection $items): RenderedSnapshotGroup
{
$renderer = $this->registry->rendererFor($policyType);
$fallbackRenderer = $this->registry->fallbackRenderer();
$renderingError = null;
$technicalPayload = $this->technicalPayload($items);
try {
$renderedItems = $items
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $renderer->render($item))
->all();
} catch (Throwable) {
$renderedItems = $items
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item))
->all();
$renderingError = 'Structured rendering failed for this policy type. Fallback metadata is shown instead.';
}
/** @var array<int, RenderedSnapshotItem> $renderedItems */
$groupFidelity = FidelityState::aggregate(array_map(
static fn (RenderedSnapshotItem $item): FidelityState => $item->fidelity,
$renderedItems,
));
$gapSummary = GapSummary::merge(array_map(
static fn (RenderedSnapshotItem $item): GapSummary => $item->gapSummary,
$renderedItems,
));
if ($renderingError !== null) {
$gapSummary = $gapSummary->withMessage($renderingError);
}
$capturedAt = collect($renderedItems)
->pluck('observedAt')
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->sortDesc()
->first();
$coverageHint = $groupFidelity->coverageHint();
if ($coverageHint === null && $gapSummary->messages !== []) {
$coverageHint = $gapSummary->messages[0];
}
return new RenderedSnapshotGroup(
policyType: $policyType,
label: $this->typeLabel($policyType),
itemCount: $items->count(),
fidelity: $groupFidelity,
gapSummary: $gapSummary,
initiallyCollapsed: true,
items: $renderedItems,
renderingError: $renderingError,
coverageHint: $coverageHint,
capturedAt: is_string($capturedAt) ? $capturedAt : null,
technicalPayload: $technicalPayload,
);
}
/**
* @param Collection<int, BaselineSnapshotItem> $items
* @return array<string, mixed>
*/
private function technicalPayload(Collection $items): array
{
return [
'items' => $items
->map(static fn (BaselineSnapshotItem $item): array => [
'snapshot_item_id' => (int) $item->getKey(),
'policy_type' => (string) $item->policy_type,
'meta_jsonb' => is_array($item->meta_jsonb) ? $item->meta_jsonb : [],
])
->all(),
];
}
/**
* @param array<string, mixed> $summary
*/
private function summaryGapCount(array $summary): int
{
$gaps = is_array($summary['gaps'] ?? null) ? $summary['gaps'] : [];
$count = $gaps['count'] ?? 0;
return is_numeric($count) ? (int) $count : 0;
}
/**
* @param array<string, mixed> $summary
*/
private function fidelitySummary(array $summary): string
{
$counts = is_array($summary['fidelity_counts'] ?? null)
? $summary['fidelity_counts']
: [];
$content = is_numeric($counts['content'] ?? null) ? (int) $counts['content'] : 0;
$meta = is_numeric($counts['meta'] ?? null) ? (int) $counts['meta'] : 0;
return sprintf('Content %d, Meta %d', $content, $meta);
}
private function typeLabel(string $policyType): string
{
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
?? InventoryPolicyTypeMeta::label($policyType)
?? Str::headline($policyType);
}
private function formatTimestamp(?string $value): string
{
if ($value === null || trim($value) === '') {
return '—';
}
try {
return Carbon::parse($value)->toDayDateTimeString();
} catch (Throwable) {
return $value;
}
}
}