<?php
/**
 * Sanitizer for Schema Architecture V2 settings payloads.
 *
 * Accepts automatic/identity/output preferences and catalogue presentation
 * prefs only. Never accepts specialized “enable for all posts” catalogues.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Sanitize and validate V2 Schema-tab settings requests.
 */
class SchemaSettingsSanitizer {

	/**
	 * Hard maximum JSON/request body size in bytes.
	 */
	const MAX_PAYLOAD_BYTES = 8192;

	/**
	 * Maximum collapsed section ids retained.
	 */
	const MAX_COLLAPSED_SECTIONS = 20;

	/**
	 * Allow-listed automatic optional component keys.
	 *
	 * @var string[]
	 */
	const AUTOMATIC_KEYS = array( 'breadcrumbs', 'search_action' );

	/**
	 * Allow-listed catalogue presentation section ids.
	 *
	 * @var string[]
	 */
	const CATALOGUE_SECTIONS = array( 'output', 'automatic', 'additional', 'templates', 'advanced' );

	/**
	 * Top-level keys that must never be accepted on the V2 settings path.
	 *
	 * @var string[]
	 */
	const REJECTED_TOP_LEVEL = array(
		'schemas',
		'enabled_types',
		'types',
		'global_types',
		'__proto__',
		'constructor',
		'prototype',
	);

	/**
	 * Allowed top-level keys for a V2 settings payload.
	 *
	 * @var string[]
	 */
	const ALLOWED_TOP_LEVEL = array(
		'schema_enabled',
		'publisher_identity',
		'automatic',
		'catalogue',
		'settings_revision',
	);

	/**
	 * Sanitize a raw V2 settings payload.
	 *
	 * @param mixed $raw     Decoded JSON/array or JSON string.
	 * @param array $context Optional context: `payload_bytes` (int).
	 * @return array{
	 *   ok:bool,
	 *   settings:array<string,mixed>,
	 *   errors:string[],
	 *   ignored:string[],
	 *   rejected:string[]
	 * }
	 */
	public static function sanitize( $raw, $context = array() ) {
		$errors   = array();
		$ignored  = array();
		$rejected = array();

		$bytes = isset( $context['payload_bytes'] ) ? (int) $context['payload_bytes'] : 0;
		if ( $bytes > self::MAX_PAYLOAD_BYTES ) {
			return self::result(
				false,
				array(),
				array( 'payload_too_large' ),
				array(),
				array()
			);
		}

		if ( is_string( $raw ) ) {
			if ( strlen( $raw ) > self::MAX_PAYLOAD_BYTES ) {
				return self::result( false, array(), array( 'payload_too_large' ), array(), array() );
			}
			$decoded = json_decode( $raw, true );
			if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
				return self::result( false, array(), array( 'malformed_json' ), array(), array() );
			}
			$raw = $decoded;
		}

		if ( null === $raw || false === $raw ) {
			return self::result( false, array(), array( 'absent_payload' ), array(), array() );
		}

		if ( ! is_array( $raw ) ) {
			return self::result( false, array(), array( 'malformed_payload' ), array(), array() );
		}

		if ( self::has_prototype_like_keys( $raw ) ) {
			return self::result( false, array(), array( 'prototype_like_keys' ), array(), array( 'prototype_like_keys' ) );
		}

		foreach ( self::REJECTED_TOP_LEVEL as $bad ) {
			if ( array_key_exists( $bad, $raw ) ) {
				$rejected[] = $bad;
			}
		}
		if ( ! empty( $rejected ) ) {
			return self::result(
				false,
				array(),
				array( 'rejected_legacy_or_specialized_fields' ),
				array(),
				$rejected
			);
		}

		$settings = array();

		if ( array_key_exists( 'schema_enabled', $raw ) ) {
			$enabled = self::sanitize_yes_no( $raw['schema_enabled'] );
			if ( null === $enabled ) {
				$errors[] = 'invalid_schema_enabled';
			} else {
				$settings['schema_enabled'] = $enabled;
			}
		}

		if ( array_key_exists( 'publisher_identity', $raw ) ) {
			$identity = self::sanitize_identity( $raw['publisher_identity'] );
			if ( null === $identity ) {
				$errors[] = 'invalid_publisher_identity';
			} else {
				$settings['publisher_identity'] = $identity;
			}
		}

		if ( array_key_exists( 'automatic', $raw ) ) {
			$automatic = self::sanitize_automatic( $raw['automatic'], $ignored, $errors );
			if ( null !== $automatic ) {
				$settings['automatic'] = $automatic;
			}
		}

		if ( array_key_exists( 'catalogue', $raw ) ) {
			$catalogue = self::sanitize_catalogue( $raw['catalogue'], $ignored, $errors );
			if ( null !== $catalogue ) {
				$settings['catalogue'] = $catalogue;
			}
		}

		if ( array_key_exists( 'settings_revision', $raw ) ) {
			$revision = self::sanitize_revision( $raw['settings_revision'] );
			if ( null === $revision ) {
				$errors[] = 'invalid_settings_revision';
			} else {
				$settings['settings_revision'] = $revision;
			}
		}

		foreach ( array_keys( $raw ) as $key ) {
			$key = (string) $key;
			if ( ! in_array( $key, self::ALLOWED_TOP_LEVEL, true ) ) {
				$ignored[] = $key;
			}
		}

		$ok = empty( $errors );
		return self::result( $ok, $settings, $errors, array_values( array_unique( $ignored ) ), $rejected );
	}

	/**
	 * Default automatic optional components (all on).
	 *
	 * @return array<string,bool>
	 */
	public static function default_automatic() {
		return array(
			'breadcrumbs'   => true,
			'search_action' => true,
		);
	}

	/**
	 * Default catalogue presentation preferences.
	 *
	 * @return array{show_extension_types:bool,collapsed_sections:string[]}
	 */
	public static function default_catalogue() {
		return array(
			'show_extension_types' => true,
			'collapsed_sections'   => array(),
		);
	}

	/**
	 * Merge sanitized partial settings over stored defaults.
	 *
	 * @param array $sanitized Sanitized settings (may be partial).
	 * @param array $current   Current stored settings snapshot.
	 * @return array
	 */
	public static function merge_with_current( array $sanitized, array $current ) {
		$merged = array(
			'schema_enabled'     => isset( $current['schema_enabled'] ) ? (string) $current['schema_enabled'] : 'yes',
			'publisher_identity' => isset( $current['publisher_identity'] ) ? (string) $current['publisher_identity'] : 'organization',
			'automatic'          => self::default_automatic(),
			'catalogue'          => self::default_catalogue(),
		);

		if ( isset( $current['automatic'] ) && is_array( $current['automatic'] ) ) {
			$merged['automatic'] = array_merge( $merged['automatic'], self::coerce_automatic( $current['automatic'] ) );
		}
		if ( isset( $current['catalogue'] ) && is_array( $current['catalogue'] ) ) {
			$merged['catalogue'] = array_merge( $merged['catalogue'], self::coerce_catalogue( $current['catalogue'] ) );
		}

		if ( isset( $sanitized['schema_enabled'] ) ) {
			$merged['schema_enabled'] = $sanitized['schema_enabled'];
		}
		if ( isset( $sanitized['publisher_identity'] ) ) {
			$merged['publisher_identity'] = $sanitized['publisher_identity'];
		}
		if ( isset( $sanitized['automatic'] ) && is_array( $sanitized['automatic'] ) ) {
			$merged['automatic'] = array_merge( $merged['automatic'], $sanitized['automatic'] );
		}
		if ( isset( $sanitized['catalogue'] ) && is_array( $sanitized['catalogue'] ) ) {
			$merged['catalogue'] = array_merge( $merged['catalogue'], $sanitized['catalogue'] );
			if ( isset( $sanitized['catalogue']['collapsed_sections'] ) ) {
				$merged['catalogue']['collapsed_sections'] = $sanitized['catalogue']['collapsed_sections'];
			}
		}

		return $merged;
	}

	/**
	 * Build a sanitizer result payload.
	 *
	 * @param bool     $ok       Success.
	 * @param array    $settings Settings.
	 * @param string[] $errors   Errors.
	 * @param string[] $ignored  Ignored keys.
	 * @param string[] $rejected Rejected keys.
	 * @return array
	 */
	private static function result( $ok, array $settings, array $errors, array $ignored, array $rejected ) {
		return array(
			'ok'       => (bool) $ok,
			'settings' => $settings,
			'errors'   => $errors,
			'ignored'  => $ignored,
			'rejected' => $rejected,
		);
	}

	/**
	 * Normalize a yes/no flag.
	 *
	 * @param mixed $value Raw.
	 * @return string|null yes|no
	 */
	private static function sanitize_yes_no( $value ) {
		if ( is_bool( $value ) ) {
			return $value ? 'yes' : 'no';
		}
		if ( is_int( $value ) || is_float( $value ) ) {
			if ( 1 === (int) $value ) {
				return 'yes';
			}
			if ( 0 === (int) $value ) {
				return 'no';
			}
			return null;
		}
		if ( ! is_string( $value ) ) {
			return null;
		}
		$normalized = strtolower( trim( $value ) );
		if ( in_array( $normalized, array( 'yes', 'true', '1', 'on' ), true ) ) {
			return 'yes';
		}
		if ( in_array( $normalized, array( 'no', 'false', '0', 'off' ), true ) ) {
			return 'no';
		}
		return null;
	}

	/**
	 * Normalize publisher identity enum.
	 *
	 * @param mixed $value Raw.
	 * @return string|null
	 */
	private static function sanitize_identity( $value ) {
		if ( ! is_string( $value ) ) {
			return null;
		}
		$identity = strtolower( trim( $value ) );
		if ( ! in_array( $identity, SchemaArchitectureSettings::IDENTITY_MODES, true ) ) {
			return null;
		}
		return $identity;
	}

	/**
	 * Sanitize optional automatic component toggles.
	 *
	 * @param mixed    $value   Raw automatic map.
	 * @param string[] $ignored Ignored keys collector.
	 * @param string[] $errors  Errors collector.
	 * @return array<string,bool>|null
	 */
	private static function sanitize_automatic( $value, array &$ignored, array &$errors ) {
		if ( ! is_array( $value ) ) {
			$errors[] = 'invalid_automatic';
			return null;
		}
		if ( self::has_prototype_like_keys( $value ) ) {
			$errors[] = 'prototype_like_keys';
			return null;
		}

		$clean = array();
		foreach ( $value as $key => $raw_flag ) {
			$key = (string) $key;
			if ( ! in_array( $key, self::AUTOMATIC_KEYS, true ) ) {
				// Specialized enable maps must not sneak in via automatic.
				if ( self::looks_like_type_enable( $key, $raw_flag ) ) {
					$errors[] = 'rejected_specialized_enable';
					return null;
				}
				$ignored[] = 'automatic.' . $key;
				continue;
			}
			$bool = self::sanitize_bool( $raw_flag );
			if ( null === $bool ) {
				$errors[] = 'invalid_automatic_' . $key;
				continue;
			}
			$clean[ $key ] = $bool;
		}

		return $clean;
	}

	/**
	 * Sanitize catalogue presentation preferences.
	 *
	 * @param mixed    $value   Raw catalogue map.
	 * @param string[] $ignored Ignored keys collector.
	 * @param string[] $errors  Errors collector.
	 * @return array|null
	 */
	private static function sanitize_catalogue( $value, array &$ignored, array &$errors ) {
		if ( ! is_array( $value ) ) {
			$errors[] = 'invalid_catalogue';
			return null;
		}
		if ( self::has_prototype_like_keys( $value ) ) {
			$errors[] = 'prototype_like_keys';
			return null;
		}

		// Hard reject attempts to store per-type site-wide enables in catalogue prefs.
		foreach ( array( 'enabled', 'enabled_types', 'types', 'schemas' ) as $bad ) {
			if ( array_key_exists( $bad, $value ) ) {
				$errors[] = 'rejected_specialized_enable';
				return null;
			}
		}

		$clean = array();

		if ( array_key_exists( 'show_extension_types', $value ) ) {
			$bool = self::sanitize_bool( $value['show_extension_types'] );
			if ( null === $bool ) {
				$errors[] = 'invalid_catalogue_show_extension_types';
			} else {
				$clean['show_extension_types'] = $bool;
			}
		}

		if ( array_key_exists( 'collapsed_sections', $value ) ) {
			$sections = self::sanitize_collapsed_sections( $value['collapsed_sections'], $ignored, $errors );
			if ( null !== $sections ) {
				$clean['collapsed_sections'] = $sections;
			}
		}

		foreach ( array_keys( $value ) as $key ) {
			$key = (string) $key;
			if ( ! in_array( $key, array( 'show_extension_types', 'collapsed_sections' ), true ) ) {
				$ignored[] = 'catalogue.' . $key;
			}
		}

		return $clean;
	}

	/**
	 * Sanitize collapsed section ids.
	 *
	 * @param mixed    $value   Raw sections.
	 * @param string[] $ignored Ignored collector.
	 * @param string[] $errors  Errors collector.
	 * @return string[]|null
	 */
	private static function sanitize_collapsed_sections( $value, array &$ignored, array &$errors ) {
		if ( ! is_array( $value ) ) {
			$errors[] = 'invalid_collapsed_sections';
			return null;
		}
		if ( count( $value ) > self::MAX_COLLAPSED_SECTIONS ) {
			$errors[] = 'oversized_collapsed_sections';
			return null;
		}

		$clean = array();
		foreach ( $value as $section ) {
			if ( ! is_string( $section ) ) {
				$ignored[] = 'catalogue.collapsed_sections.non_string';
				continue;
			}
			$section = sanitize_key( $section );
			if ( ! in_array( $section, self::CATALOGUE_SECTIONS, true ) ) {
				$ignored[] = 'catalogue.collapsed_sections.' . $section;
				continue;
			}
			$clean[] = $section;
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Normalize a non-negative settings revision.
	 *
	 * @param mixed $value Raw.
	 * @return int|null
	 */
	private static function sanitize_revision( $value ) {
		if ( is_int( $value ) || ( is_string( $value ) && ctype_digit( $value ) ) ) {
			$revision = (int) $value;
			return $revision >= 0 ? $revision : null;
		}
		if ( is_float( $value ) && floor( $value ) === $value && $value >= 0 ) {
			return (int) $value;
		}
		return null;
	}

	/**
	 * Normalize a boolean-like value.
	 *
	 * @param mixed $value Raw.
	 * @return bool|null
	 */
	private static function sanitize_bool( $value ) {
		if ( is_bool( $value ) ) {
			return $value;
		}
		if ( is_int( $value ) || is_float( $value ) ) {
			if ( 1 === (int) $value ) {
				return true;
			}
			if ( 0 === (int) $value ) {
				return false;
			}
			return null;
		}
		if ( ! is_string( $value ) ) {
			return null;
		}
		$normalized = strtolower( trim( $value ) );
		if ( in_array( $normalized, array( 'true', '1', 'yes', 'on' ), true ) ) {
			return true;
		}
		if ( in_array( $normalized, array( 'false', '0', 'no', 'off' ), true ) ) {
			return false;
		}
		return null;
	}

	/**
	 * Detect keys that look like Schema.org type site-wide enables.
	 *
	 * @param string $key   Key.
	 * @param mixed  $value Value.
	 * @return bool
	 */
	private static function looks_like_type_enable( $key, $value ) {
		$known = array(
			'FAQPage',
			'HowTo',
			'Recipe',
			'Event',
			'Course',
			'JobPosting',
			'SoftwareApplication',
			'Review',
			'Product',
			'Article',
			'WebSite',
			'Organization',
		);
		if ( in_array( $key, $known, true ) ) {
			return true;
		}
		if ( is_bool( $value ) && preg_match( '/^[A-Z][A-Za-z0-9]+$/', $key ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Coerce a stored automatic map onto defaults.
	 *
	 * @param array $value Raw automatic.
	 * @return array<string,bool>
	 */
	private static function coerce_automatic( array $value ) {
		$clean = self::default_automatic();
		foreach ( self::AUTOMATIC_KEYS as $key ) {
			if ( array_key_exists( $key, $value ) ) {
				$bool = self::sanitize_bool( $value[ $key ] );
				if ( null !== $bool ) {
					$clean[ $key ] = $bool;
				}
			}
		}
		return $clean;
	}

	/**
	 * Coerce a stored catalogue map onto defaults.
	 *
	 * @param array $value Raw catalogue.
	 * @return array
	 */
	private static function coerce_catalogue( array $value ) {
		$clean = self::default_catalogue();
		if ( array_key_exists( 'show_extension_types', $value ) ) {
			$bool = self::sanitize_bool( $value['show_extension_types'] );
			if ( null !== $bool ) {
				$clean['show_extension_types'] = $bool;
			}
		}
		if ( isset( $value['collapsed_sections'] ) && is_array( $value['collapsed_sections'] ) ) {
			$sections = array();
			foreach ( $value['collapsed_sections'] as $section ) {
				if ( ! is_string( $section ) ) {
					continue;
				}
				$section = sanitize_key( $section );
				if ( in_array( $section, self::CATALOGUE_SECTIONS, true ) ) {
					$sections[] = $section;
				}
			}
			$clean['collapsed_sections'] = array_values( array_unique( $sections ) );
		}
		return $clean;
	}

	/**
	 * Recursively detect prototype-pollution style keys.
	 *
	 * @param array $data Data.
	 * @return bool
	 */
	private static function has_prototype_like_keys( array $data ) {
		foreach ( $data as $key => $value ) {
			$key = (string) $key;
			if ( in_array( $key, array( '__proto__', 'constructor', 'prototype' ), true ) ) {
				return true;
			}
			if ( is_array( $value ) && self::has_prototype_like_keys( $value ) ) {
				return true;
			}
		}
		return false;
	}
}
