<?php
/**
 * REST request validator.
 *
 * Stateless collection of small pure functions implementing the
 * §17.1 ("入力検証") "すべての query param / body を validate"
 * mandate documented in `specifications/rest-api-spec.md` plus the
 * §5.1 / §5.2 ("ページング" / "ソート") common-query-parameter
 * grammar.
 *
 * Each method accepts the running {@see ValidationResult}
 * accumulator and a value to inspect, and returns a (possibly
 * extended) {@see ValidationResult}. The chaining contract lets a
 * controller compose a per-request validation pipeline without
 * losing earlier failures:
 *
 *   ```
 *   $result = ValidationResult::valid();
 *   $result = $validator->requireString( $result, 'keywords', $kw, 1, 200 );
 *   $result = $validator->requireOneOf( $result, 'provider', $p, array( 'amazon', 'rakuten' ) );
 *   $result = $validator->requirePagination( $result, $page, $perPage );
 *   if ( ! $result->isValid() ) {
 *       return $result->toEnvelope( $ctx )->toArray();
 *   }
 *   ```
 *
 * Validation policy:
 *
 *   - Each method appends at most ONE message per failure mode it
 *     detects on the value. Methods that combine several checks
 *     (for example {@see self::requireString()} which validates
 *     presence + type + length) short-circuit on the first failure
 *     so the §4.4 envelope is not noisier than necessary, but
 *     callers may invoke multiple methods on the same field to
 *     accumulate independent failures.
 *
 *   - Messages are concise English strings (the admin UI is
 *     responsible for translating). The grammar is "Sentence
 *     case ending with a period."
 *
 *   - The `optionalString` / `optionalInt` variants accept null /
 *     missing without recording an error; non-null values are
 *     subject to the same checks as the required variants.
 *
 *   - The §5.1 / §5.2 helpers ({@see self::requirePagination()},
 *     {@see self::requireSort()}) encode the common-query
 *     boundaries so individual controllers do not re-implement
 *     the page≥1 / per_page∈[1,50] / order∈{asc,desc} rules.
 *
 * The validator carries no state and is safe to share as a
 * singleton across controllers.
 *
 * @package Affilun\Presentation\Rest\Validation
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Validation;

/**
 * Stateless §17.1 / §5.1 / §5.2 input-validation helpers.
 *
 * See the file-level docblock for the chaining contract used with
 * {@see ValidationResult} and the per-method validation policy.
 */
final class RequestValidator {

	/**
	 * Validate that `$value` is a non-empty string within the given
	 * length bounds and append a message to `$r` when not.
	 *
	 * Failure modes (short-circuited in this order):
	 *
	 *   - `$value` is not a string → "Must be a string."
	 *   - Trimmed length below `$minLen` → "Too short (minimum N characters)."
	 *   - Length above `$maxLen` → "Too long (maximum N characters)."
	 *
	 * @param ValidationResult $r      Running accumulator.
	 * @param string           $field  Field name.
	 * @param mixed            $value  Value to inspect.
	 * @param int              $minLen Minimum length, default 1.
	 * @param int              $maxLen Maximum length, default 255.
	 */
	public function requireString(
		ValidationResult $r,
		string $field,
		mixed $value,
		int $minLen = 1,
		int $maxLen = 255
	): ValidationResult {
		if ( ! is_string( $value ) ) {
			return $r->withField( $field, 'Must be a string.' );
		}

		$length = strlen( $value );
		if ( $length < $minLen ) {
			return $r->withField(
				$field,
				sprintf( 'Too short (minimum %d characters).', $minLen )
			);
		}

		if ( $length > $maxLen ) {
			return $r->withField(
				$field,
				sprintf( 'Too long (maximum %d characters).', $maxLen )
			);
		}

		return $r;
	}

	/**
	 * Validate that `$value` is an integer within the given bounds.
	 *
	 * Numeric strings are accepted and coerced; non-numeric values
	 * fail the type check.
	 *
	 * @param ValidationResult $r     Running accumulator.
	 * @param string           $field Field name.
	 * @param mixed            $value Value to inspect.
	 * @param int              $min   Minimum allowed value.
	 * @param int              $max   Maximum allowed value.
	 */
	public function requireInt(
		ValidationResult $r,
		string $field,
		mixed $value,
		int $min = PHP_INT_MIN,
		int $max = PHP_INT_MAX
	): ValidationResult {
		if ( ! $this->isIntegerLike( $value ) ) {
			return $r->withField( $field, 'Must be an integer.' );
		}

		$intValue = (int) $value;
		if ( $intValue < $min ) {
			return $r->withField(
				$field,
				sprintf( 'Must be at least %d.', $min )
			);
		}

		if ( $intValue > $max ) {
			return $r->withField(
				$field,
				sprintf( 'Must be at most %d.', $max )
			);
		}

		return $r;
	}

	/**
	 * Validate that `$value` is a boolean (or boolean-equivalent
	 * scalar accepted by `filter_var(FILTER_VALIDATE_BOOLEAN)`).
	 *
	 * @param ValidationResult $r     Running accumulator.
	 * @param string           $field Field name.
	 * @param mixed            $value Value to inspect.
	 */
	public function requireBool(
		ValidationResult $r,
		string $field,
		mixed $value
	): ValidationResult {
		if ( is_bool( $value ) ) {
			return $r;
		}

		$filtered = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
		if ( $filtered === null ) {
			return $r->withField( $field, 'Must be a boolean.' );
		}

		return $r;
	}

	/**
	 * Validate that `$value` is exactly one of the allowed strings.
	 *
	 * @param ValidationResult $r       Running accumulator.
	 * @param string           $field   Field name.
	 * @param mixed            $value   Value to inspect.
	 * @param list<string>     $allowed Whitelist of allowed values.
	 */
	public function requireOneOf(
		ValidationResult $r,
		string $field,
		mixed $value,
		array $allowed
	): ValidationResult {
		if ( ! is_string( $value ) || ! in_array( $value, $allowed, true ) ) {
			return $r->withField(
				$field,
				sprintf( 'Must be one of: %s.', implode( ', ', $allowed ) )
			);
		}

		return $r;
	}

	/**
	 * Validate that `$value` is a list (zero-indexed sequential
	 * array) with no more than `$maxItems` entries.
	 *
	 * Non-list arrays (associative or sparse-keyed) are rejected
	 * because the §17.1 / §5.3 batch-input contracts uniformly
	 * expect list-shaped input.
	 *
	 * @param ValidationResult $r        Running accumulator.
	 * @param string           $field    Field name.
	 * @param mixed            $value    Value to inspect.
	 * @param int              $maxItems Maximum item count, default 100.
	 */
	public function requireArray(
		ValidationResult $r,
		string $field,
		mixed $value,
		int $maxItems = 100
	): ValidationResult {
		if ( ! is_array( $value ) || ! array_is_list( $value ) ) {
			return $r->withField( $field, 'Must be a list.' );
		}

		if ( count( $value ) > $maxItems ) {
			return $r->withField(
				$field,
				sprintf( 'Too many items (maximum %d).', $maxItems )
			);
		}

		return $r;
	}

	/**
	 * Optional-string variant of {@see self::requireString()}.
	 *
	 * Null is accepted without recording an error; non-null values
	 * are subject to the same checks as the required variant.
	 *
	 * @param ValidationResult $r      Running accumulator.
	 * @param string           $field  Field name.
	 * @param mixed            $value  Value to inspect.
	 * @param int              $minLen Minimum length when non-null.
	 * @param int              $maxLen Maximum length when non-null.
	 */
	public function optionalString(
		ValidationResult $r,
		string $field,
		mixed $value,
		int $minLen = 1,
		int $maxLen = 255
	): ValidationResult {
		if ( $value === null ) {
			return $r;
		}

		return $this->requireString( $r, $field, $value, $minLen, $maxLen );
	}

	/**
	 * Optional-integer variant of {@see self::requireInt()}.
	 *
	 * Null is accepted without recording an error; non-null values
	 * are subject to the same checks as the required variant.
	 *
	 * @param ValidationResult $r     Running accumulator.
	 * @param string           $field Field name.
	 * @param mixed            $value Value to inspect.
	 * @param int              $min   Minimum allowed value.
	 * @param int              $max   Maximum allowed value.
	 */
	public function optionalInt(
		ValidationResult $r,
		string $field,
		mixed $value,
		int $min = PHP_INT_MIN,
		int $max = PHP_INT_MAX
	): ValidationResult {
		if ( $value === null ) {
			return $r;
		}

		return $this->requireInt( $r, $field, $value, $min, $max );
	}

	/**
	 * Validate the §5.1 pagination tuple.
	 *
	 * Encodes the §5.1 boundaries:
	 *
	 *   - `page` is an integer ≥ 1.
	 *   - `per_page` is an integer in [1, 50].
	 *
	 * Errors are appended under the `page` / `per_page` field
	 * names so the §4.4 envelope's `fields` map matches the
	 * documented query parameter names.
	 *
	 * @param ValidationResult $r       Running accumulator.
	 * @param mixed            $page    `page` query value.
	 * @param mixed            $perPage `per_page` query value.
	 */
	public function requirePagination(
		ValidationResult $r,
		mixed $page,
		mixed $perPage
	): ValidationResult {
		$result = $this->requireInt( $r, 'page', $page, 1, PHP_INT_MAX );
		$result = $this->requireInt( $result, 'per_page', $perPage, 1, 50 );

		return $result;
	}

	/**
	 * Validate the §5.2 sort tuple.
	 *
	 * Encodes the §5.2 boundaries:
	 *
	 *   - `sort` is a string in `$allowedSortKeys`.
	 *   - `order` is one of `'asc'` / `'desc'`.
	 *
	 * Errors are appended under the `sort` / `order` field names
	 * so the §4.4 envelope's `fields` map matches the documented
	 * query parameter names.
	 *
	 * @param ValidationResult $r                Running accumulator.
	 * @param mixed            $sort             `sort` query value.
	 * @param mixed            $order            `order` query value.
	 * @param list<string>     $allowedSortKeys  Whitelist of sort keys.
	 */
	public function requireSort(
		ValidationResult $r,
		mixed $sort,
		mixed $order,
		array $allowedSortKeys
	): ValidationResult {
		$result = $this->requireOneOf( $r, 'sort', $sort, $allowedSortKeys );
		$result = $this->requireOneOf( $result, 'order', $order, array( 'asc', 'desc' ) );

		return $result;
	}

	/**
	 * Report whether `$value` parses as an integer.
	 *
	 * Accepts `int` directly and numeric strings whose decoded
	 * representation round-trips losslessly to `int` (so `'1'` and
	 * `'1000'` pass but `'1.5'` and `'1e3'` do not).
	 *
	 * @param mixed $value Value to inspect.
	 */
	private function isIntegerLike( mixed $value ): bool {
		if ( is_int( $value ) ) {
			return true;
		}

		if ( is_string( $value ) && $value !== '' ) {
			if ( $value[0] === '-' ) {
				$digits = substr( $value, 1 );
				return $digits !== '' && ctype_digit( $digits );
			}
			return ctype_digit( $value );
		}

		return false;
	}
}
