<?php
/**
 * REST validation-result value object.
 *
 * Immutable value object that accumulates per-field validation
 * errors gathered while parsing a REST request body / query string,
 * and converts the accumulated errors into the §4.4
 * ("フィールドエラー形式") error envelope shape documented in
 * `specifications/rest-api-spec.md`.
 *
 * The type is the application-layer counterpart to the §17.1
 * ("入力検証") "すべての query param / body を validate" mandate:
 * each {@see RequestValidator} method returns a (possibly extended)
 * {@see self} so the controller can chain validations without
 * losing earlier failures, then dispatch on
 * {@see self::isValid()} to decide whether to invoke the use case
 * or short-circuit to {@see self::toEnvelope()}.
 *
 * Field semantics:
 *
 *   - `fieldErrors` is `array<string, list<string>>` keyed by field
 *     name; values are non-empty lists of human-readable error
 *     messages (concise English; the admin UI is responsible for
 *     translating). Field names are validated against the
 *     `^[a-z][a-z0-9_]*$` grammar so a typo (`'kwords '`) fails
 *     loudly here rather than silently producing an unparseable
 *     §4.4 envelope.
 *
 *   - {@see self::isValid()} returns true iff `fieldErrors` is the
 *     empty map. The §17.1 mandate is satisfied by treating any
 *     non-empty map as a hard refusal of the request.
 *
 *   - {@see self::withField()} appends a single message to the list
 *     for the given field, preserving ordering across chained
 *     validator calls. The caller does not need to know whether the
 *     field already has errors.
 *
 *   - {@see self::toEnvelope()} delegates to
 *     {@see ErrorEnvelope::validation()} so the §4.4 wrapping logic
 *     ("`details.fields = $fieldErrors`", code
 *     `affilun_validation_failed`, status 422) lives in exactly one
 *     place.
 *
 *   - {@see self::equals()} performs a deep comparison so tests can
 *     assert validator output without leaking the internal
 *     representation.
 *
 * Validation failures raise {@see InvalidArgumentException}; the
 * messages are developer-facing and never rendered to HTML.
 *
 * @package Affilun\Presentation\Rest\Validation
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Validation;

use Affilun\Presentation\Rest\RequestContext;
use Affilun\Presentation\Rest\Response\ErrorEnvelope;
use InvalidArgumentException;

/**
 * Immutable per-field validation error accumulator.
 *
 * See the file-level docblock for the mapping to
 * `rest-api-spec.md` §4.4 / §17.1, the per-field validation rules,
 * and the chaining contract used by {@see RequestValidator}.
 */
final class ValidationResult {

	/**
	 * Grammar for field names.
	 *
	 * Held as a private constant so the constructor and
	 * {@see self::withField()} share the same regex literal.
	 */
	private const FIELD_GRAMMAR = '/^[a-z][a-z0-9_]*$/';

	/**
	 * Map of field name → list of error messages.
	 *
	 * Keys are field names matching {@see self::FIELD_GRAMMAR};
	 * values are non-empty lists of trimmed error messages.
	 *
	 * @var array<string, list<string>>
	 */
	private array $fieldErrors;

	/**
	 * Construct a validation result with explicit field errors.
	 *
	 * Empty per-field lists are rejected because they would render
	 * an §4.4 envelope whose `fields` map carries an empty array
	 * for a field name, which is meaningless. Callers that want an
	 * empty result should use {@see self::valid()}.
	 *
	 * @param array<string, list<string>> $fieldErrors Per-field error map.
	 *
	 * @throws InvalidArgumentException When any field name violates
	 *                                  the grammar, or when any
	 *                                  per-field message list is
	 *                                  empty / contains a non-string
	 *                                  / blank entry.
	 */
	public function __construct( array $fieldErrors ) {
		$normalized = array();

		foreach ( $fieldErrors as $field => $messages ) {
			if ( ! is_string( $field ) || preg_match( self::FIELD_GRAMMAR, $field ) !== 1 ) {
				$message = sprintf(
					'ValidationResult field names must match %s; got "%s".',
					self::FIELD_GRAMMAR,
					is_string( $field ) ? $field : gettype( $field )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! is_array( $messages ) || $messages === array() ) {
				$invalid = sprintf(
					'ValidationResult per-field message list for "%s" must be a non-empty list.',
					$field
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $invalid );
			}

			$entries = array();
			foreach ( $messages as $entry ) {
				if ( ! is_string( $entry ) ) {
					$invalid = sprintf(
						'ValidationResult messages for "%s" must be strings; got %s.',
						$field,
						gettype( $entry )
					);
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
					throw new InvalidArgumentException( $invalid );
				}

				$trimmed = trim( $entry );
				if ( $trimmed === '' ) {
					$invalid = sprintf(
						'ValidationResult messages for "%s" must be non-empty trimmed strings.',
						$field
					);
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
					throw new InvalidArgumentException( $invalid );
				}

				$entries[] = $trimmed;
			}

			$normalized[ $field ] = $entries;
		}

		$this->fieldErrors = $normalized;
	}

	/**
	 * Report whether this result carries no field errors.
	 */
	public function isValid(): bool {
		return $this->fieldErrors === array();
	}

	/**
	 * Per-field error map accumulated by this result.
	 *
	 * @return array<string, list<string>>
	 */
	public function fieldErrors(): array {
		return $this->fieldErrors;
	}

	/**
	 * Return a copy of this result with `$message` appended to the
	 * list for `$field`.
	 *
	 * If the field already has errors the new message is appended
	 * to the existing list, preserving ordering. The caller does
	 * not need to know whether the field already has errors.
	 *
	 * @param string $field   Field name (validated against
	 *                        {@see self::FIELD_GRAMMAR}).
	 * @param string $message Concise English error message.
	 *
	 * @throws InvalidArgumentException When `$field` violates the
	 *                                  grammar or `$message` is
	 *                                  empty after trimming.
	 */
	public function withField( string $field, string $message ): self {
		if ( preg_match( self::FIELD_GRAMMAR, $field ) !== 1 ) {
			$invalid = sprintf(
				'ValidationResult::withField() field must match %s; got "%s".',
				self::FIELD_GRAMMAR,
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		$trimmed = trim( $message );
		if ( $trimmed === '' ) {
			$invalid = sprintf(
				'ValidationResult::withField() message for "%s" must be a non-empty trimmed string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		$next           = $this->fieldErrors;
		$existing       = $next[ $field ] ?? array();
		$existing[]     = $trimmed;
		$next[ $field ] = $existing;

		$copy              = clone $this;
		$copy->fieldErrors = $next;

		return $copy;
	}

	/**
	 * Convenience factory returning an empty (valid) result.
	 */
	public static function valid(): self {
		return new self( array() );
	}

	/**
	 * Convert the accumulated field errors into the §4.4 error
	 * envelope shape.
	 *
	 * Delegates to {@see ErrorEnvelope::validation()} so the §4.4
	 * wrapping logic lives in exactly one place.
	 *
	 * @param RequestContext $ctx     Per-request meta source.
	 * @param string         $message Human-readable summary.
	 */
	public function toEnvelope(
		RequestContext $ctx,
		string $message = 'Validation failed.'
	): ErrorEnvelope {
		return ErrorEnvelope::validation( $this->fieldErrors, $ctx, $message );
	}

	/**
	 * Deep equality check against another result.
	 *
	 * Returns true when both results carry the same set of field
	 * names and identical message lists in identical order.
	 *
	 * @param self $other Result to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->fieldErrors === $other->fieldErrors;
	}
}
