<?php
defined( 'ABSPATH' ) || exit;

/**
 * Deterministic parser and validator for Agent 06 visual plans.
 *
 * The model proposes the scene. PHP independently canonicalizes its visual
 * signature, verifies asset coverage and rejects unsafe or repetitive plans.
 */
final class Costudio_Visual_Plan {

	public const CONTRACT_VERSION   = '1';
	public const VOCABULARY_VERSION = '1';

	private const DIMENSIONS = array(
		'subject_family',
		'scene_family',
		'composition_family',
		'lighting_family',
		'palette_family',
	);

	/**
	 * Editorial-role taxonomy (Ciclo 3, Fatia 6). Separate from `role`
	 * (cover/inline — a SLOT, derived from filename/position) on purpose:
	 * `editorial_role` is a FUNCTION the asset serves, and an inline asset
	 * can serve any of the 4 non-cover roles. Confusing the two by
	 * overloading a single field was the original plan's design smell.
	 */
	private const EDITORIAL_ROLES = array( 'cover', 'explanatory', 'conceptual', 'evidence-data', 'product-ui' );

	/**
	 * Version 1 is deliberately small. It was derived from the current A06
	 * corpus and is a classification vocabulary, not a creative menu.
	 */
	private const VOCABULARY = array(
		'subject_family' => array(
			'human-action', 'human-portrait', 'tool-object', 'document-process',
			'data-interface', 'environment', 'abstract-concept', 'product-reference',
			'still-life', 'none',
		),
		'scene_family' => array(
			'workspace', 'home', 'office', 'outdoors', 'institutional', 'industrial',
			'studio', 'digital-abstract', 'macro-detail', 'none',
		),
		'composition_family' => array(
			'close-up', 'medium-action', 'wide-context', 'overhead-flat-lay',
			'split-comparison', 'layered-depth', 'diagrammatic', 'centered-hero',
		),
		'lighting_family' => array(
			'natural-soft', 'natural-hard', 'studio-soft', 'studio-dramatic',
			'high-key', 'low-key', 'mixed-practical', 'ambient-flat',
		),
		'palette_family' => array(
			'brand-neutral', 'brand-accent', 'warm-natural', 'cool-natural',
			'monochrome', 'high-contrast', 'muted-editorial', 'source-faithful',
		),
	);

	private const ALIASES = array(
		'subject_family' => array(
			'person working' => 'human-action', 'people working' => 'human-action', 'working hands' => 'human-action',
			'person' => 'human-portrait', 'portrait' => 'human-portrait', 'tool' => 'tool-object', 'device' => 'tool-object',
			'paperwork' => 'document-process', 'document' => 'document-process', 'workflow' => 'document-process',
			'dashboard' => 'data-interface', 'interface' => 'data-interface', 'screen' => 'data-interface',
			'landscape' => 'environment', 'architecture' => 'environment', 'concept' => 'abstract-concept',
			'product' => 'product-reference', 'objects' => 'still-life', 'flat lay objects' => 'still-life',
		),
		'scene_family' => array(
			'desk' => 'workspace', 'workstation' => 'workspace', 'living room' => 'home', 'kitchen' => 'home',
			'corporate office' => 'office', 'meeting room' => 'office', 'nature' => 'outdoors', 'street' => 'outdoors',
			'clinic' => 'institutional', 'school' => 'institutional', 'factory' => 'industrial', 'warehouse' => 'industrial',
			'photo studio' => 'studio', 'abstract background' => 'digital-abstract', 'digital space' => 'digital-abstract',
			'macro' => 'macro-detail', 'detail' => 'macro-detail',
		),
		'composition_family' => array(
			'close up' => 'close-up', 'macro shot' => 'close-up', 'medium shot' => 'medium-action',
			'wide shot' => 'wide-context', 'environmental shot' => 'wide-context', 'flat lay' => 'overhead-flat-lay',
			'overhead' => 'overhead-flat-lay', 'split screen' => 'split-comparison', 'before and after' => 'split-comparison',
			'layers' => 'layered-depth', 'foreground' => 'layered-depth', 'diagram' => 'diagrammatic',
			'infographic' => 'diagrammatic', 'hero' => 'centered-hero', 'centered' => 'centered-hero',
		),
		'lighting_family' => array(
			'soft daylight' => 'natural-soft', 'natural light' => 'natural-soft', 'window light' => 'natural-soft',
			'hard daylight' => 'natural-hard', 'direct sunlight' => 'natural-hard', 'softbox' => 'studio-soft',
			'dramatic light' => 'studio-dramatic', 'rim light' => 'studio-dramatic', 'bright' => 'high-key',
			'dark' => 'low-key', 'practical lights' => 'mixed-practical', 'ambient light' => 'ambient-flat',
			'flat light' => 'ambient-flat',
		),
		'palette_family' => array(
			'neutral' => 'brand-neutral', 'brand colors' => 'brand-accent', 'accent color' => 'brand-accent',
			'warm' => 'warm-natural', 'earthy' => 'warm-natural', 'cool' => 'cool-natural',
			'black and white' => 'monochrome', 'monochromatic' => 'monochrome', 'contrast' => 'high-contrast',
			'muted' => 'muted-editorial', 'desaturated' => 'muted-editorial', 'realistic colors' => 'source-faithful',
		),
	);

	private function __construct() {}

	public static function vocabulary(): array {
		return self::VOCABULARY;
	}

	public static function extract( string $output ): ?array {
		$open  = stripos( $output, '<VISUAL_PLAN>' );
		$close = false === $open ? false : stripos( $output, '</VISUAL_PLAN>', $open );
		if ( false === $open || false === $close ) {
			return null;
		}
		$json    = substr( $output, $open + strlen( '<VISUAL_PLAN>' ), $close - ( $open + strlen( '<VISUAL_PLAN>' ) ) );
		$decoded = json_decode( trim( $json ), true );
		return is_array( $decoded ) ? $decoded : null;
	}

	public static function replace_block( string $output, array $plan ): string {
		$json = function_exists( 'wp_json_encode' )
			? wp_json_encode( $plan, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
			: json_encode( $plan, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		return (string) preg_replace( '/<VISUAL_PLAN>.*?<\/VISUAL_PLAN>/is', "<VISUAL_PLAN>\n" . $json . "\n</VISUAL_PLAN>", $output, 1 );
	}

	public static function strip_block( string $output ): string {
		$clean = (string) preg_replace( '/\s*<VISUAL_PLAN>.*?<\/VISUAL_PLAN>\s*/is', "\n", $output, 1 );
		// A truncated model response must not leave a raw contract fragment that
		// a legacy JSON parser could accidentally treat as a usable inventory.
		$clean = (string) preg_replace( '/\s*<VISUAL_PLAN>.*\z/is', "\n", $clean, 1 );
		return trim( $clean );
	}

	/** Return the exact asset inventory expected from the Writer output. */
	public static function expected_images_from_article( string $article, bool $include_cover = true ): array {
		$expected = $include_cover ? array( 'imagem1.webp' ) : array();
		if ( preg_match_all( '/\[IMG:\s*([^\]|\s]+\.webp)\b/iu', $article, $matches ) ) {
			foreach ( $matches[1] as $filename ) {
				$normalized = self::normalize_filename( (string) $filename );
				if ( '' !== $normalized && ! in_array( $normalized, $expected, true ) ) {
					$expected[] = $normalized;
				}
			}
		}
		return $expected;
	}

	/**
	 * Validate and canonicalize a plan without network/model calls.
	 *
	 * @param array $context_by_filename Optional, keyed by normalized filename:
	 *              ['allows_data_visualization' => bool, ...] — the SAME
	 *              deterministic context packet the model was given
	 *              (Costudio_Pipeline_Summaries::build_visual_context_block()),
	 *              recomputed by the caller. Without it, editorial_role is
	 *              still tracked and normalized but the evidence-data rule
	 *              cannot be enforced (nothing to check the claim against),
	 *              matching the plan's "degrade safely, never fabricate" rule.
	 */
	public static function validate_and_normalize( array $plan, array $expected_images = array(), array $context_by_filename = array() ): array {
		$errors   = array();
		$warnings = array();
		$assets   = isset( $plan['assets'] ) && is_array( $plan['assets'] ) ? $plan['assets'] : array();
		if ( array() === $assets ) {
			$errors[] = self::issue( 'visual_plan_assets_missing' );
		}

		$normalized_assets = array();
		$seen_filenames    = array();
		foreach ( $assets as $index => $asset ) {
			if ( ! is_array( $asset ) ) {
				$errors[] = self::issue( 'visual_asset_invalid', 'asset-' . ( $index + 1 ) );
				continue;
			}

			$filename = self::normalize_filename( (string) ( $asset['filename'] ?? '' ) );
			$id       = self::asset_id( $asset, $filename, $index );
			$prompt   = self::clean_text( (string) ( $asset['prompt'] ?? '' ), 4000 );
			if ( '' === $filename ) {
				$errors[] = self::issue( 'visual_asset_filename_missing', $id );
				continue;
			}
			if ( '' === $prompt ) {
				$errors[] = self::issue( 'visual_asset_prompt_missing', $id, $filename );
				continue;
			}
			if ( isset( $seen_filenames[ $filename ] ) ) {
				$errors[] = self::issue( 'visual_asset_filename_duplicate', $id, $filename );
				continue;
			}
			$seen_filenames[ $filename ] = true;

			$role           = self::normalize_role( (string) ( $asset['role'] ?? '' ), $filename, $index );
			$editorial_role = self::normalize_editorial_role( (string) ( $asset['editorial_role'] ?? '' ), $role );
			$context_anchor = self::clean_text( (string) ( $asset['context_anchor'] ?? ( $asset['context'] ?? '' ) ), 600 );
			$structured     = array(
				'subject_family'     => self::field_text( $asset, array( 'subject', 'action' ) ),
				'scene_family'       => self::field_text( $asset, array( 'environment', 'context_anchor', 'context' ) ),
				'composition_family' => self::field_text( $asset, array( 'composition', 'camera' ) ),
				'lighting_family'    => self::field_text( $asset, array( 'lighting' ) ),
				'palette_family'     => self::field_text( $asset, array( 'palette' ) ),
			);
			$claimed         = isset( $asset['visual_signature'] ) && is_array( $asset['visual_signature'] ) ? $asset['visual_signature'] : array();
			$signature       = array();
			$asset_warnings  = array();
			foreach ( self::DIMENSIONS as $dimension ) {
				$claimed_value = trim( (string) ( $claimed[ $dimension ] ?? ( $claimed[ str_replace( '_family', '', $dimension ) ] ?? '' ) ) );
				$canonical     = self::canonical_family( $dimension, $structured[ $dimension ], $claimed_value, $prompt );
				$signature[ $dimension ] = $canonical;

				// Purely informational: canonical_family() deliberately ignores the
				// model's self-claimed label when deriving the signature (see its
				// docblock) so a scene can never talk its way past repetition
				// detection. Surfacing the drift here — without changing pass/fail —
				// is what makes the modal's per-asset "Warnings" field mean something
				// on real data instead of always being empty.
				if ( '' !== $claimed_value && strtolower( $claimed_value ) !== $canonical ) {
					$asset_warnings[] = 'visual_signature_overridden_' . $dimension;
				}
			}

			$normalized = array(
				'id'                => $id,
				'filename'          => $filename,
				'marker'            => self::clean_text( (string) ( $asset['marker'] ?? '' ), 500 ),
				'role'              => $role,
				'editorial_role'    => $editorial_role,
				'position'          => self::clean_text( (string) ( $asset['position'] ?? '' ), 160 ),
				'context_anchor'    => $context_anchor,
				'subject'           => self::clean_text( (string) ( $asset['subject'] ?? '' ), 500 ),
				'action'            => self::clean_text( (string) ( $asset['action'] ?? '' ), 500 ),
				'environment'       => self::clean_text( (string) ( $asset['environment'] ?? '' ), 500 ),
				'composition'       => self::clean_text( (string) ( $asset['composition'] ?? '' ), 500 ),
				'camera'            => self::clean_text( (string) ( $asset['camera'] ?? '' ), 300 ),
				'lighting'          => self::clean_text( (string) ( $asset['lighting'] ?? '' ), 300 ),
				'palette'           => self::clean_list( $asset['palette'] ?? array(), 8, 100 ),
				'visual_signature'  => $signature,
				'editorial_purpose' => self::clean_text( (string) ( $asset['editorial_purpose'] ?? '' ), 600 ),
				'avoid'             => self::clean_list( $asset['avoid'] ?? array(), 12, 180 ),
				'prompt'            => $prompt,
				'alt_text'          => self::clean_text( (string) ( $asset['alt_text'] ?? ( $asset['alt'] ?? '' ) ), 300 ),
				'caption'           => self::clean_text( (string) ( $asset['caption'] ?? '' ), 500 ),
				'warnings'          => array_values( array_unique( $asset_warnings ) ),
			);

			if ( '' === $context_anchor ) {
				$errors[] = self::issue( 'visual_asset_context_anchor_missing', $id, $filename );
			}
			if ( '' === $normalized['editorial_purpose'] ) {
				$errors[] = self::issue( 'visual_asset_editorial_purpose_missing', $id, $filename );
			}
			if ( 'cover' === $role && 'imagem1.webp' !== $filename ) {
				$errors[] = self::issue( 'visual_cover_filename_invalid', $id, $filename );
			}
			if ( 'inline' === $role && ( '' === $normalized['marker'] || false === stripos( $normalized['marker'], $filename ) ) ) {
				$errors[] = self::issue( 'visual_inline_marker_invalid', $id, $filename );
			}
			// The model may claim any editorial_role, but "evidence-data" is
			// the one role that asserts a factual promise (this image
			// visualizes a real, sourced number). It can only be accepted
			// when the SAME context packet the model was given already
			// confirmed a verified fact was actually available for this
			// filename — never taken on the model's word alone. Missing
			// $context_by_filename (caller didn't recompute it) means "cannot
			// confirm", which is treated the same as "not confirmed" — degrade
			// safely, never trust an unverifiable claim.
			if ( 'evidence-data' === $editorial_role
				&& empty( $context_by_filename[ $filename ]['allows_data_visualization'] )
			) {
				$errors[] = self::issue( 'visual_evidence_role_without_verified_facts', $id, $filename );
			}
			$semantic = self::semantic_defects( $normalized );
			$errors   = array_merge( $errors, $semantic['errors'] );
			$warnings = array_merge( $warnings, $semantic['warnings'], array_map(
				static function ( string $code ) use ( $id, $filename ): array {
					return self::issue( $code, $id, $filename );
				},
				$asset_warnings
			) );
			$normalized_assets[] = $normalized;
		}

		$expected = array_values( array_unique( array_filter( array_map( array( __CLASS__, 'normalize_filename' ), $expected_images ) ) ) );
		if ( array() !== $expected ) {
			$actual = array_column( $normalized_assets, 'filename' );
			foreach ( array_values( array_diff( $expected, $actual ) ) as $filename ) {
				$errors[] = self::issue( 'visual_asset_expected_missing', '', $filename );
			}
			foreach ( array_values( array_diff( $actual, $expected ) ) as $filename ) {
				$errors[] = self::issue( 'visual_asset_unexpected', '', $filename );
			}
			if ( $actual !== $expected ) {
				$errors[] = self::issue( 'visual_asset_order_mismatch' );
			}
		}

		$cover_count = 0;
		foreach ( $normalized_assets as $asset ) {
			$cover_count += 'cover' === $asset['role'] ? 1 : 0;
		}
		if ( 1 !== $cover_count && array() !== $normalized_assets ) {
			$errors[] = self::issue( 'visual_cover_count_invalid' );
		}

		$repetition = self::detect_repetition( $normalized_assets );
		$errors     = self::unique_issues( array_merge( $errors, $repetition['errors'] ) );
		$warnings   = self::unique_issues( array_merge( $warnings, $repetition['warnings'] ) );
		$normalized_plan = array(
			'contract_version'   => self::CONTRACT_VERSION,
			'vocabulary_version' => self::VOCABULARY_VERSION,
			'visual_identity'    => self::normalize_visual_identity( $plan['visual_identity'] ?? array() ),
			'assets'             => $normalized_assets,
		);

		return array(
			'valid'    => array() === $errors,
			'errors'   => $errors,
			'warnings' => $warnings,
			'plan'     => $normalized_plan,
			'repair'   => self::repair_payload( $errors ),
		);
	}

	public static function repair_payload( array $errors ): array {
		$items        = array();
		$rejected_ids = array();
		foreach ( $errors as $error ) {
			if ( ! is_array( $error ) || empty( $error['code'] ) ) {
				continue;
			}
			if ( ! empty( $error['asset_id'] ) ) {
				$rejected_ids[] = (string) $error['asset_id'];
			}
			if ( ! empty( $error['asset_ids'] ) && is_array( $error['asset_ids'] ) ) {
				foreach ( $error['asset_ids'] as $asset_id ) {
					if ( is_scalar( $asset_id ) && '' !== trim( (string) $asset_id ) ) {
						$rejected_ids[] = trim( (string) $asset_id );
					}
				}
			}
			$items[] = array_filter(
				array(
					'asset_id' => $error['asset_id'] ?? null,
					'asset_ids' => $error['asset_ids'] ?? null,
					'filename' => $error['filename'] ?? null,
					'rule' => (string) $error['code'],
				),
				static function ( $value ) { return null !== $value && '' !== $value && array() !== $value; }
			);
		}
		return array(
			'contract_version'    => self::CONTRACT_VERSION,
			'rejected_asset_ids' => array_values( array_unique( $rejected_ids ) ),
			'rejected'           => $items,
			'instruction'        => 'Return one corrected VISUAL_PLAN. Change only rejected assets and fields. Do not repeat the article context.',
		);
	}

	private static function semantic_defects( array $asset ): array {
		$errors = array();
		$text   = self::normalize_phrase( implode( ' ', array(
			$asset['subject'], $asset['action'], $asset['environment'], $asset['composition'],
			$asset['editorial_purpose'], $asset['prompt'],
		) ) );
		$id = (string) $asset['id'];
		$filename = (string) $asset['filename'];

		$cliches = array(
			'glowing brain', 'neon brain', 'humanoid robot', 'robot at laptop', 'floating holographic icons',
			'generic handshake', 'chess pieces', 'light bulb idea', 'pile of money', 'flying dollar bills',
			'cerebro neon', 'robo humanoide', 'aperto de maos', 'bombilla idea', 'robot humanoide',
		);
		foreach ( $cliches as $cliche ) {
			if ( false !== strpos( $text, self::normalize_phrase( $cliche ) ) ) {
				$errors[] = self::issue( 'visual_cliche_detected', $id, $filename );
				break;
			}
		}
		if ( preg_match( '/\b(?:fictional|imaginary|futuristic|fake|invented|ficticio|imaginario|inventado)\b.*\b(?:dashboard|interface|app|ui|screen|painel|interfaz|pantalla)\b/i', $text ) ) {
			$errors[] = self::issue( 'visual_invented_ui', $id, $filename );
		}
		if ( preg_match( '/\b(?:chart|graph|dashboard|grafico|grafica|diagrama)\b/i', $text ) && preg_match( '/\b\d+(?:[.,]\d+)?%?\b/', $asset['prompt'] ) && ! preg_match( '/\b(?:source|sourced|provided|verified|fonte|fuente|verificado)\b/i', $text ) ) {
			$errors[] = self::issue( 'visual_unverified_chart_data', $id, $filename );
		}
		if ( preg_match( '/\b(?:text (?:saying|reading)|headline text|words reading|texto (?:dizendo|que diga)|palabras que digan)\b/i', $asset['prompt'] ) ) {
			$errors[] = self::issue( 'visual_long_readable_text_requested', $id, $filename );
		}
		return array( 'errors' => $errors, 'warnings' => array() );
	}

	private static function detect_repetition( array $assets ): array {
		$errors = array();
		$still_life = 0;
		foreach ( $assets as $asset ) {
			$still_life += 'still-life' === ( $asset['visual_signature']['subject_family'] ?? '' ) ? 1 : 0;
		}
		if ( $still_life > 1 ) {
			$errors[] = self::issue( 'visual_repetition_still_life_limit' );
		}

		$count = count( $assets );
		for ( $left = 0; $left < $count; $left++ ) {
			for ( $right = $left + 1; $right < $count; $right++ ) {
				$a = $assets[ $left ];
				$b = $assets[ $right ];
				$matches = 0;
				foreach ( self::DIMENSIONS as $dimension ) {
					$matches += self::same_meaningful_family( $a, $b, $dimension ) ? 1 : 0;
				}
				$local = 0;
				foreach ( array( 'subject_family', 'scene_family', 'composition_family', 'lighting_family' ) as $dimension ) {
					$local += self::same_meaningful_family( $a, $b, $dimension ) ? 1 : 0;
				}
				if ( self::high_token_overlap( (string) ( $a['subject'] ?? '' ), (string) ( $b['subject'] ?? '' ) )
					|| self::high_token_overlap( (string) ( $a['environment'] ?? '' ), (string) ( $b['environment'] ?? '' ) )
				) {
					$local++;
				}
				$ids = array( (string) $a['id'], (string) $b['id'] );
				if ( $matches / count( self::DIMENSIONS ) >= 0.80 ) {
					$errors[] = self::issue( 'visual_repetition_near_duplicate', '', '', $ids );
				} elseif ( $local >= 3 ) {
					$errors[] = self::issue( 'visual_repetition_local_collision', '', '', $ids );
				}
				$is_cover_inline = ( 'cover' === $a['role'] && 'inline' === $b['role'] ) || ( 'inline' === $a['role'] && 'cover' === $b['role'] );
				if ( $is_cover_inline && self::same_meaningful_family( $a, $b, 'subject_family' ) && self::same_meaningful_family( $a, $b, 'scene_family' ) ) {
					$errors[] = self::issue( 'visual_repetition_cover_inline_subject_scene', '', '', $ids );
				}
			}
		}
		return array( 'errors' => self::unique_issues( $errors ), 'warnings' => array() );
	}

	private static function same_meaningful_family( array $a, array $b, string $dimension ): bool {
		$left  = (string) ( $a['visual_signature'][ $dimension ] ?? '' );
		$right = (string) ( $b['visual_signature'][ $dimension ] ?? '' );
		return '' !== $left && 'none' !== $left && 'other:unspecified' !== $left && $left === $right;
	}

	private static function canonical_family( string $dimension, string $structured, string $claimed, string $prompt ): string {
		// Deliberately ignore the model-authored visual_signature. The validator
		// derives families from observable fields/prompt text; trusting the label
		// would let the same scene declare itself "diverse" and bypass repetition
		// detection. The parameter remains for contract compatibility only.
		unset( $claimed );
		foreach ( array( $structured, $prompt ) as $candidate ) {
			$match = self::family_from_text( $dimension, $candidate );
			if ( '' !== $match ) {
				return $match;
			}
		}
		return 'other:' . self::unknown_tokens( $structured . ' ' . $prompt );
	}

	private static function family_from_text( string $dimension, string $text ): string {
		$normalized = self::normalize_phrase( $text );
		foreach ( self::VOCABULARY[ $dimension ] ?? array() as $canonical ) {
			$needle = str_replace( '-', ' ', $canonical );
			if ( preg_match( '/(?:^|\s)' . preg_quote( $needle, '/' ) . '(?:\s|$)/i', str_replace( '-', ' ', $normalized ) ) ) {
				return $canonical;
			}
		}
		foreach ( self::ALIASES[ $dimension ] ?? array() as $alias => $canonical ) {
			if ( false !== strpos( $normalized, self::normalize_phrase( $alias ) ) ) {
				return $canonical;
			}
		}
		return '';
	}

	private static function field_text( array $asset, array $keys ): string {
		$parts = array();
		foreach ( $keys as $key ) {
			if ( isset( $asset[ $key ] ) ) {
				$parts[] = is_array( $asset[ $key ] ) ? implode( ' ', $asset[ $key ] ) : (string) $asset[ $key ];
			}
		}
		return implode( ' ', $parts );
	}

	private static function normalize_visual_identity( $identity ): array {
		if ( ! is_array( $identity ) ) {
			return array();
		}
		return array(
			'medium'            => self::clean_text( (string) ( $identity['medium'] ?? '' ), 200 ),
			'color_grade'       => self::clean_text( (string) ( $identity['color_grade'] ?? '' ), 200 ),
			'palette'           => self::clean_list( $identity['palette'] ?? array(), 8, 100 ),
			'texture'           => self::clean_text( (string) ( $identity['texture'] ?? '' ), 200 ),
			'brand_accent_rule' => self::clean_text( (string) ( $identity['brand_accent_rule'] ?? '' ), 300 ),
		);
	}

	private static function clean_list( $value, int $limit, int $item_max ): array {
		$items = is_array( $value ) ? $value : preg_split( '/[,;]+/', (string) $value );
		$out = array();
		foreach ( array_slice( $items, 0, $limit ) as $item ) {
			$clean = self::clean_text( (string) $item, $item_max );
			if ( '' !== $clean ) {
				$out[] = $clean;
			}
		}
		return $out;
	}

	private static function unknown_tokens( string $text ): string {
		$tokens = self::significant_tokens( $text );
		return array() === $tokens ? 'unspecified' : implode( '-', array_slice( $tokens, 0, 4 ) );
	}

	/** Punctuation/stopword-stripped significant tokens, Unicode-safe, no mbstring dependency. */
	private static function significant_tokens( string $text ): array {
		$normalized = self::normalize_phrase( $text );
		$tokens     = preg_split( '/\s+/u', $normalized, -1, PREG_SPLIT_NO_EMPTY ) ?: array();
		$stop       = array( 'a', 'an', 'and', 'the', 'of', 'in', 'on', 'with', 'de', 'da', 'do', 'das', 'dos', 'e', 'em', 'com', 'la', 'el', 'los', 'las', 'y', 'en', 'con' );
		return array_values( array_unique( array_filter( $tokens, static function ( $token ) use ( $stop ) {
			return strlen( $token ) > 1 && ! in_array( $token, $stop, true );
		} ) ) );
	}

	/**
	 * Auxiliary repetition signal the plan specified and the first pass never
	 * implemented: canonical_family() only recognizes the small versioned
	 * vocabulary/alias list, so two genuinely similar scenes described in
	 * different words can both fall into different (or both `other:...`)
	 * buckets and never register as a family match. Comparing significant
	 * tokens directly on `subject`/`environment` catches that paraphrase case
	 * without needing an LLM call — deterministic and local, per the plan's
	 * own constraint. Requires at least 2 significant tokens on each side,
	 * mirroring the same floor the near-duplicate family check already uses,
	 * so one shared generic word can never trigger it alone.
	 */
	private static function high_token_overlap( string $left, string $right ): bool {
		$a = self::significant_tokens( $left );
		$b = self::significant_tokens( $right );
		if ( count( $a ) < 2 || count( $b ) < 2 ) {
			return false;
		}
		$intersection = count( array_intersect( $a, $b ) );
		$smaller      = min( count( $a ), count( $b ) );
		return $smaller > 0 && ( $intersection / $smaller ) >= 0.60;
	}

	private static function normalize_phrase( string $text ): string {
		$text = function_exists( 'remove_accents' ) ? remove_accents( $text ) : $text;
		$text = function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text );
		$text = preg_replace( '/[^\p{L}\p{N}]+/u', ' ', $text );
		return trim( (string) preg_replace( '/\s+/u', ' ', (string) $text ) );
	}

	/** Public so callers building a filename-keyed lookup (e.g. the context-packet cross-check) normalize identically. */
	public static function normalize_filename( string $filename ): string {
		$filename = strtolower( trim( $filename ) );
		$filename = preg_replace( '/[^a-z0-9._-]+/i', '-', basename( str_replace( '\\', '/', $filename ) ) );
		$filename = trim( (string) $filename, '.-_' );
		if ( '' === $filename ) {
			return '';
		}
		$base = trim( pathinfo( $filename, PATHINFO_FILENAME ), '.-_' );
		return '' === $base ? '' : $base . '.webp';
	}

	private static function normalize_role( string $role, string $filename, int $index ): string {
		$role = strtolower( trim( $role ) );
		return in_array( $role, array( 'cover', 'inline' ), true ) ? $role : ( 'imagem1.webp' === $filename || 0 === $index ? 'cover' : 'inline' );
	}

	/**
	 * `role` (cover/inline) is a SLOT; `editorial_role` is the FUNCTION the
	 * asset serves within that slot. A cover slot is always editorial_role
	 * "cover" regardless of what the model claims — that part isn't the
	 * model's decision. An inline slot defaults to "explanatory" (the safest,
	 * least fact-asserting role) when the model omits the field or claims
	 * something outside the fixed taxonomy — never silently promoted to
	 * "evidence-data", which is exactly the role the validator holds to a
	 * higher bar.
	 */
	private static function normalize_editorial_role( string $claimed, string $slot_role ): string {
		if ( 'cover' === $slot_role ) {
			return 'cover';
		}
		$claimed = strtolower( trim( $claimed ) );
		return in_array( $claimed, self::EDITORIAL_ROLES, true ) && 'cover' !== $claimed ? $claimed : 'explanatory';
	}

	private static function asset_id( array $asset, string $filename, int $index ): string {
		$id = preg_replace( '/[^a-z0-9_-]+/i', '-', (string) ( $asset['id'] ?? '' ) );
		return '' !== trim( (string) $id, '-' ) ? trim( (string) $id, '-' ) : 'asset-' . ( $index + 1 ) . '-' . substr( sha1( $filename ), 0, 8 );
	}

	private static function clean_text( string $text, int $max ): string {
		$text = trim( (string) preg_replace( '/\s+/u', ' ', strip_tags( $text ) ) );
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $text, 0, $max, 'UTF-8' );
		}
		if ( preg_match_all( '/./us', $text, $chars ) ) {
			return implode( '', array_slice( $chars[0], 0, $max ) );
		}
		return substr( $text, 0, $max );
	}

	private static function issue( string $code, string $asset_id = '', string $filename = '', array $asset_ids = array() ): array {
		$issue = array( 'code' => $code );
		if ( '' !== $asset_id ) {
			$issue['asset_id'] = $asset_id;
		}
		if ( '' !== $filename ) {
			$issue['filename'] = $filename;
		}
		if ( array() !== $asset_ids ) {
			$issue['asset_ids'] = array_values( $asset_ids );
		}
		return $issue;
	}

	private static function unique_issues( array $issues ): array {
		$unique = array();
		foreach ( $issues as $issue ) {
			$unique[ md5( serialize( $issue ) ) ] = $issue;
		}
		return array_values( $unique );
	}
}
