<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\EventSubscriber;
use App\Domain\Entity\Expediente;
use App\Shared\Infrastructure\Form\Extension\FormInvalidBuffer;
use App\Shared\Infrastructure\Observability\ExpedienteTag;
use App\Shared\Infrastructure\Observability\ObservabilityLogger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Throwable;
/**
* FIX-260521-003-ter — [OBS-ADMIN-EDIT-FORMERR] captura señales de fallo de
* validación en POST a admin expediente edit (caso A4 Subsanación arbitraje
* donde el servidor responde 200 pero el form se re-renderiza con errores
* y el usuario ve "el mismo mensaje" sin que aparezca exception en log).
*
* Heurística: para rutas admin edit con POST y status 200 (no redirect a
* list), volcar al canal OBS información estructurada del request +
* mensajes de flash bag de Sonata Admin (sonata_flash_error si existe).
* No accede al Form (no está en Response event); solo señales externas.
*/
final class AdminEditFormErrorSubscriber implements EventSubscriberInterface
{
private const WATCHED_ROUTES = [
'admin_app_domain_expediente_edit',
'admin_app_domain_tramite_edit',
];
public function __construct(
private ObservabilityLogger $observabilityLogger,
private ?RequestStack $requestStack = null,
private ?TokenStorageInterface $tokenStorage = null,
private ?EntityManagerInterface $em = null,
private ?FormInvalidBuffer $formInvalidBuffer = null
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::RESPONSE => ['onKernelResponse', -512],
];
}
public function onKernelResponse(ResponseEvent $event): void
{
try {
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
if ($request->getMethod() !== 'POST') {
return;
}
$route = (string) $request->attributes->get('_route');
if (!in_array($route, self::WATCHED_ROUTES, true)) {
return;
}
$status = $event->getResponse()->getStatusCode();
if ($status !== 200) {
return;
}
$flashErrors = $this->extractFlashErrors();
$body = $event->getResponse()->getContent() ?: '';
// FIX-260623 — resolver Expediente una vez para enriquecer OBS con
// expediente_codigo. Cacheado en $expediente para reusar despues en
// findRequiredEmptyFields y evitar segundo find(). NUNCA rompe.
$expedienteId = $request->attributes->get('id');
$expediente = null;
if ($this->em !== null && $expedienteId !== null && $expedienteId !== '') {
try {
$expediente = $this->em->getRepository(Expediente::class)->find((string) $expedienteId);
} catch (Throwable $e) {
$expediente = null;
}
}
$context = ExpedienteTag::for($expediente) + [
'route' => $route,
'path' => $request->getPathInfo(),
'expediente_id' => $expedienteId,
'tramite_key' => $request->query->get('tramite'),
'uniqid' => $request->query->get('uniqid'),
'user' => $this->resolveUser(),
'post_keys_tree'=> $this->keyTree($request->request->all(), 4),
'has_files' => count($request->files->all()) > 0,
'flash_errors' => $flashErrors,
'response_size' => strlen($body),
'form_errors' => $this->scanInlineFormErrors($body),
];
$this->observabilityLogger->safeLog(
'warning',
'[OBS-ADMIN-EDIT-FORMERR] post_returned_200_no_redirect',
$context
);
// FIX-260623-001 — Flash explicativo cuando hay POST con botón submit
// pero la transición no completa (200 no redirect). Cubre 2 casos:
// (a) submit-button no-fire (handler no disparó porque botón no es
// identificado como submitter del form HTML).
// (b) required-empty bloqueando el save de Sonata.
// En ambos el toast genérico "Se ha producido un error" no informa
// qué falta — pintamos los campos required-vacíos detectados.
//
// FIX-260625-002 — el heurístico findClickedSubmitButton solo detecta
// botones cuyo padre contiene "boton" en el nombre. Eso EXCLUYE casos
// legítimos: A2 tiene su submit en `iniciosolicitudarbitraje`
// (parent NO contiene "boton") → el flash nunca se emitía aunque hubiera
// errores reales del root form (FormInvalidBuffer poblado por la
// extension FIX-260623-003). Resultado en cliente: solo veía
// "No puede ser vacio" inline sin breadcrumb del campo concreto.
// Solución: invocar emitExplanatoryFlash SIEMPRE que haya errores en
// el buffer (independiente de clicked button); usar clicked como
// ENRIQUECIMIENTO (button_name humano) cuando esté disponible. Si no
// hay clicked y el buffer está vacío, el flash final cae al genérico.
$clicked = $this->findClickedSubmitButton($request->request->all());
$missing = [];
if ($clicked !== null) {
$missing = $this->findRequiredEmptyFields(
(string) $context['expediente_id'],
$clicked,
$request->request->all(),
$expediente
);
$this->observabilityLogger->safeLog(
'warning',
'[OBS-HANDLER-NO-FIRE] submit_button_post_transition_did_not_complete',
$context + [
'clicked_button' => $clicked,
'missing_required' => $missing,
]
);
}
// FIX-260625-002 — emit flash siempre que tengamos algo útil que decir:
// - clicked button detectado, O
// - root form errors en el buffer (FormInvalidBuffer poblado), O
// - heurística de missing required del schema.
// Sin condiciones extra (un flash genérico es mejor que ningún flash
// cuando "No puede ser vacio" aparece inline sin breadcrumb).
$hasBufferErrors = !empty($this->formInvalidBuffer?->getErrors() ?? []);
if ($clicked !== null || $hasBufferErrors) {
$this->emitExplanatoryFlash($clicked, $missing);
// FIX-260625-003 — extender OBS con métricas de resolución de
// path real via cause (ConstraintViolation::getPropertyPath()).
$bufferErrors = $this->formInvalidBuffer?->getErrors() ?? [];
$resolvedCount = 0;
$sampleResolved = [];
foreach ($bufferErrors as $be) {
if (!empty($be['real_path_via_cause'])) {
$resolvedCount++;
if (count($sampleResolved) < 3) {
$sampleResolved[] = (string) ($be['path'] ?? '');
}
}
}
// OBS: trazabilidad del path que disparó el flash.
$this->observabilityLogger->safeLog(
'info',
'[OBS-ADMIN-EDIT-FORMERR] explanatory_flash_emitted',
$context + [
'clicked_detected' => $clicked !== null,
'buffer_errors_count' => count($bufferErrors),
'missing_schema' => count($missing),
'paths_resolved_via_cause'=> $resolvedCount,
'paths_unresolved_count' => max(0, count($bufferErrors) - $resolvedCount),
'field_paths_sample' => $sampleResolved,
]
);
}
} catch (Throwable $e) {
// swallow.
}
}
/**
* Localiza dentro del POST un botón submit "clickeado" (valor truthy en
* una sub-key cuyo nombre contiene "boton" o cuyo padre contiene
* "botonarbitraje"). Devuelve el path como lista de keys.
*
* @return array{path: array<int,string>, button_name: string}|null
*/
private function findClickedSubmitButton(array $post): ?array
{
$found = null;
$walk = function ($arr, array $path) use (&$walk, &$found) {
if ($found !== null) return;
foreach ($arr as $k => $v) {
$nextPath = $path;
$nextPath[] = (string) $k;
if (is_array($v)) {
$walk($v, $nextPath);
if ($found !== null) return;
} else {
// Botones submit en JsonFieldType se nombran como hijos
// de "botonarbitraje*", "boton*", "botones*". Cuando se
// pulsan, Sonata incluye un valor scalar (vacío o el text).
$parentKey = count($path) > 0 ? (string) $path[count($path) - 1] : '';
$isInBotonContainer = stripos($parentKey, 'boton') !== false;
if ($isInBotonContainer && is_string($v)) {
$found = [
'path' => $nextPath,
'button_name' => (string) $k,
];
return;
}
}
}
};
$walk($post, []);
return $found;
}
/**
* Carga el tramite del expediente cuyo subform contiene el botón clickado,
* y devuelve la lista de campos required-vacíos (labels humanizados).
*
* @param array{path: array<int,string>, button_name: string} $clicked
* @return array<int,string>
*/
private function findRequiredEmptyFields(string $expedienteId, array $clicked, array $post, ?Expediente $expediente = null): array
{
if ($this->em === null) return [];
try {
// Path típico: [uniqid, 'tramites', N, 'form', SubformKey, 'botonarbitraje...', button_name]
$path = $clicked['path'];
$tIdx = null;
for ($i = 0; $i < count($path) - 1; $i++) {
if ($path[$i] === 'tramites' && isset($path[$i + 1]) && ctype_digit((string) $path[$i + 1])) {
$tIdx = (int) $path[$i + 1];
break;
}
}
if ($tIdx === null) return [];
// FIX-260623 — reusar $expediente ya cargado en onKernelResponse si
// viene; fallback al find() previo para invocaciones aisladas.
if ($expediente === null) {
$expediente = $this->em->getRepository(Expediente::class)->find($expedienteId);
}
if ($expediente === null) return [];
$tramites = $expediente->getTramites() ?? [];
// Reindex sin deleted_at
$alive = [];
foreach ($tramites as $t) {
if (method_exists($t, 'getDeletedAt') && $t->getDeletedAt() !== null) continue;
$alive[] = $t;
}
if (!isset($alive[$tIdx])) return [];
$tramite = $alive[$tIdx];
$schemaJson = method_exists($tramite, 'getSchema') ? $tramite->getSchema() : null;
if (!is_string($schemaJson) || $schemaJson === '') return [];
$schema = json_decode($schemaJson, true);
if (!is_array($schema)) return [];
$properties = $schema['properties'] ?? [];
// Datos POST de este tramite
$tramitePost = [];
for ($i = 0; $i < count($path); $i++) {
if ($path[$i] === 'tramites' && isset($path[$i + 1])) {
$uniqid = $path[0] ?? null;
if ($uniqid !== null && isset($post[$uniqid]['tramites'][$tIdx]['form'])) {
$tramitePost = $post[$uniqid]['tramites'][$tIdx]['form'];
}
break;
}
}
$missing = [];
$this->collectRequiredEmpty($properties, $tramitePost, '', $missing);
return $missing;
} catch (Throwable $e) {
return [];
}
}
/**
* Recorre recursivamente properties del schema buscando required-vacíos
* en $data (rama del POST). Acumula labels humanos en $out.
*/
private function collectRequiredEmpty(array $properties, $data, string $crumb, array &$out): void
{
if (!is_array($data)) $data = [];
foreach ($properties as $name => $prop) {
if (!is_array($prop)) continue;
$type = $prop['type'] ?? '';
$title = $prop['title'] ?? $name;
$title = is_string($title) ? $title : (string) $name;
$titleHuman = $this->humanizeLabel($title);
$required = $prop['options']['required'] ?? false;
$branchCrumb = $titleHuman; // solo el nombre del campo, no breadcrumb
$value = $data[$name] ?? null;
$isEmpty = $value === null || $value === '' || $value === [] || $value === false;
if ($required && $isEmpty && $type !== 'object' && $type !== 'submit') {
$out[] = $branchCrumb;
}
if ($type === 'object' && isset($prop['properties']) && is_array($prop['properties'])) {
$this->collectRequiredEmpty($prop['properties'], $value, $branchCrumb, $out);
}
}
}
/**
* Convierte un label tipo "label.responsable_encargado" o
* "label.seleccion_subfase" a texto legible cuando no hay traductor
* disponible: quita el prefix "label.", reemplaza "_" por espacio y
* capitaliza palabras.
*/
private function humanizeLabel(string $label): string
{
$s = preg_replace('/^label\./', '', $label);
$s = str_replace('_', ' ', (string) $s);
return ucwords(trim($s));
}
/**
* Emite flash de error explicativo. Prioridad de información:
* (a) errores REALES del root form (vía FormInvalidBuffer del
* FormInvalidDiagnosticsExtension) — verdad absoluta.
* (b) si no hay buffer, lista de required-vacíos del schema del
* tramite — heurística.
* (c) mensaje genérico si nada se identificó.
*
* El (a) es preferible porque cubre errores en CUALQUIER parte del
* root form (cabecera, otros tramites, etc.), no solo el tramite
* cuyo botón se pulsó. Caso real: cabecera.Intervinientes[N].Interviniente
* vacío bloquea el submit y el flash con (b) solo decía "Responsable
* Encargado" (false positive del schema del tramite).
*/
private function emitExplanatoryFlash(?array $clicked, array $missing): void
{
try {
$session = $this->requestStack?->getSession();
if ($session === null || !method_exists($session, 'getFlashBag')) return;
// FIX-260625-002: $clicked puede ser null si el heuristico
// findClickedSubmitButton no detecto el boton (su parent no contiene
// "boton" en el nombre, p.ej. A2 iniciosolicitudarbitraje). En ese
// caso usamos el label generico "continuar" como nombre del boton.
$btn = $clicked['button_name'] ?? 'continuar';
// (a) errores reales del root form (preferido)
$bufferErrors = $this->formInvalidBuffer?->getErrors() ?? [];
if (!empty($bufferErrors)) {
// FIX-260625-002 — dedup por (path,msg) para no repetir el mismo
// breadcrumb dos veces cuando varios errores del root form
// bubble al mismo nodo.
$seen = [];
$paths = [];
foreach ($bufferErrors as $e) {
$key = ($e['path'] ?? '') . '|' . ($e['msg'] ?? '');
if (isset($seen[$key])) continue;
$seen[$key] = true;
$paths[] = $this->humanizePath($e['path'] ?? '') . ' (' . ($e['msg'] ?? '') . ')';
if (count($paths) >= 5) break;
}
$msg = sprintf(
'No se ha podido procesar "%s": faltan o son inválidos los campos siguientes — %s. Rellénalos y vuelve a pulsar.',
$btn,
implode(' · ', $paths)
);
$session->getFlashBag()->add('sonata_flash_error', $msg);
return;
}
// (b) heurística: required-vacíos del schema del tramite
if (!empty($missing)) {
$list = implode(', ', array_slice($missing, 0, 5));
$msg = sprintf(
'No se ha podido procesar "%s": faltan campos obligatorios (%s). Rellénalos y vuelve a pulsar.',
$btn,
$list
);
$session->getFlashBag()->add('sonata_flash_error', $msg);
return;
}
// (c) genérico
$msg = sprintf(
'No se ha podido procesar "%s". Comprueba que has rellenado todos los campos obligatorios (marcados con *) y vuelve a pulsar.',
$btn
);
$session->getFlashBag()->add('sonata_flash_error', $msg);
} catch (Throwable $e) {
// swallow
}
}
/**
* Convierte un path del FormInvalidBuffer (separado por puntos, con
* índices numéricos para collections) a un breadcrumb más humano.
* Ej: "cabecera__form.Intervinientes.2.Interviniente"
* → "Cabecera › Intervinientes › #3 › Interviniente"
*
* FIX-260625-003 — Cuando el path es SOLO el uniqid Sonata (caso en que
* FormInvalidDiagnosticsExtension::pathFromCause no pudo resolver via
* cause), seguimos devolviendo "algún campo obligatorio del formulario"
* como mensaje útil (no perfecto, pero mejor que el uniqid).
*
* Cuando el path es uniqid + subpath (poco frecuente tras FIX-260625-003,
* pero posible si la normalización deja el uniqid como primer segmento),
* descartamos el primer segmento uniqid y humanizamos el resto.
*
* Cuando el path es CamelCase o snake_case multi-palabra, separamos en
* palabras legibles (TipoSujeto → "Tipo Sujeto").
*/
private function humanizePath(string $path): string
{
if ($path === '') return '?';
$parts = explode('.', $path);
// FIX-260625-003 — caso "uniqid solo" (legacy): si tras filtrar no
// queda nada útil, devolver fallback genérico.
$onlyUniqid = (count($parts) === 1)
&& (bool) preg_match('/^s[0-9a-f]{12,}$/i', $parts[0]);
if ($onlyUniqid) {
return 'algún campo obligatorio del formulario';
}
$out = [];
foreach ($parts as $idx => $p) {
if (ctype_digit($p)) {
$out[] = '#' . ((int) $p + 1);
continue;
}
// Uniqid Sonata como primer segmento (legacy): omitir.
if ($idx === 0 && preg_match('/^s[0-9a-f]{12,}$/i', $p)) {
continue;
}
$clean = preg_replace('/^cabecera__form$/', 'Cabecera', $p);
// FIX-260625-003 — separar CamelCase (TipoSujeto → "Tipo Sujeto")
// para paths que vienen del PropertyPath del Validator.
$clean = preg_replace('/_+/', ' ', (string) $clean);
$clean = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', (string) $clean);
$out[] = ucwords(trim((string) $clean));
}
if (empty($out)) {
return 'algún campo obligatorio del formulario';
}
return implode(' › ', $out);
}
/**
* Walk POST array up to maxDepth, return only the key tree (no values).
* Helps see which form sections were submitted without leaking data.
*/
private function keyTree(array $arr, int $maxDepth, int $depth = 0): array
{
$out = [];
foreach ($arr as $k => $v) {
if (is_array($v) && $depth < $maxDepth) {
$sub = $this->keyTree($v, $maxDepth, $depth + 1);
$out[(string) $k] = empty($sub) ? '<empty>' : $sub;
} else {
$out[(string) $k] = is_array($v) ? '<truncated_array>' : '<scalar>';
}
}
return $out;
}
/**
* Look for inline form error markers in the rendered HTML response.
* Symfony/Twig default form theme: .sonata-ba-field-error, .has-error,
* <ul class="list-unstyled"><li>error message</li>. Also Sonata flash
* sections (#sonata-flash-error).
*
* @return array{count_has_error:int, count_sonata_ba_error:int, samples:array<int,string>}
*/
private function scanInlineFormErrors(string $body): array
{
$countHasError = substr_count($body, 'has-error');
$countSonataBaError = substr_count($body, 'sonata-ba-field-error');
$countFieldError = substr_count($body, 'form-error-message');
$countInvalidFeedb = substr_count($body, 'invalid-feedback');
$samples = [];
// Capture short snippets around the first 3 .sonata-ba-field-error or .has-error blocks.
if (preg_match_all(
'/(?:sonata-ba-field-error|has-error|form-error-message|invalid-feedback)[^<]{0,400}<[^>]*>([^<\n]{1,200})/i',
$body,
$m
)) {
foreach (array_slice($m[1], 0, 5) as $snippet) {
$clean = trim(preg_replace('/\s+/', ' ', strip_tags($snippet)));
if ($clean !== '' && strlen($clean) > 3) {
$samples[] = mb_substr($clean, 0, 180);
}
}
}
// Also: explicit Sonata flash error in HTML even if FlashBag was already peeked elsewhere.
if (preg_match('/sonata-flash-error[^>]*>([^<]{1,300})/i', $body, $m2)) {
$samples[] = 'FLASH: ' . trim(preg_replace('/\s+/', ' ', $m2[1]));
}
return [
'has_error' => $countHasError,
'sonata_ba_error' => $countSonataBaError,
'form_error_message' => $countFieldError,
'invalid_feedback' => $countInvalidFeedb,
'samples' => $samples,
];
}
/**
* @return array<int|string, mixed>
*/
private function extractFlashErrors(): array
{
try {
$session = $this->requestStack?->getSession();
if ($session === null || !method_exists($session, 'getFlashBag')) {
return [];
}
$bag = $session->getFlashBag();
$peeked = [];
foreach (['error', 'sonata_flash_error', 'danger', 'warning'] as $type) {
$msgs = $bag->peek($type);
if (!empty($msgs)) {
$peeked[$type] = $msgs;
}
}
return $peeked;
} catch (Throwable $e) {
return [];
}
}
private function resolveUser(): ?string
{
try {
$token = $this->tokenStorage?->getToken();
if ($token === null) {
return null;
}
return method_exists($token, 'getUserIdentifier')
? $token->getUserIdentifier()
: (string) $token->getUser();
} catch (Throwable $e) {
return null;
}
}
}