<?php
namespace Burst\Traits;

/**
 * Trait containing sanitization methods for consistent data cleaning throughout the application.
 */
trait Sanitize {
	use Helper;

	/**
	 * Sanitize hash fragment from URL.
	 * Allows alphanumeric, /, ?, =, &, _, -, ., % characters (% for URL encoding).
	 *
	 * @param string $hash The hash fragment to sanitize (including the # character).
	 * @return string Sanitized hash fragment.
	 */
	private static function sanitize_hash_fragment( string $hash ): string {
		if ( empty( $hash ) ) {
			return '';
		}

		// Sanitize hash: allow alphanumeric, /, ?, =, &, _, -, ., % characters.
		// Hash fragments are client-side only, but we sanitize to prevent XSS if displayed.
		return preg_replace( '/[^#a-zA-Z0-9\/?=&_\-.%]/', '', $hash );
	}

	/**
	 * Sanitize a relative URL, ensuring it starts with a slash and doesn't contain the domain.
	 *
	 * @param string $url URL to sanitize.
	 * @return string Sanitized URL.
	 */
	public function sanitize_relative_url( string $url ): string {
		$url = sanitize_text_field( $url );
		if ( empty( $url ) ) {
			return '/';
		}

		// Remove protocol and domain if present (make URL relative).
		$url_without_protocol = preg_replace( '(^https?://)', '', $url );
		if ( $url_without_protocol !== $url ) {
			// URL had a protocol, so also remove the domain.
			$parts = explode( '/', $url_without_protocol, 2 );
			if ( count( $parts ) === 2 ) {
				$url = '/' . $parts[1];
			} else {
				$url = '/';
			}
		}

		// Ensure URL starts with a slash.
		if ( strpos( $url, '/' ) !== 0 ) {
			$url = '/' . $url;
		}

		return trailingslashit( filter_var( $url, FILTER_SANITIZE_URL ) );
	}

	/**
	 * Sanitize an IP field, with each IP on a new line.
	 *
	 * @param string $value The IP field value to sanitize.
	 * @return string Sanitized IP field.
	 */
	public function sanitize_ip_field( string $value ): string {
		$ips = explode( PHP_EOL, $value );
		// Remove whitespace.
		$ips = array_map( 'trim', $ips );
		$ips = array_filter(
			$ips,
			function ( $line ) {
				return $line !== '';
			}
		);
		// Remove duplicates.
		$ips = array_unique( $ips );
		// Sanitize each ip.
		$ips = array_map( 'sanitize_text_field', $ips );
		return implode( PHP_EOL, $ips );
	}

	/**
	 * Sanitize a field value based on its type.
	 *
	 * @param mixed  $value The value to sanitize.
	 * @param string $type The field type.
	 * @return mixed Sanitized value.
	 */
	// phpcs:disable
	public function sanitize_field( $value, string $type ) {
		// phpcs:enable

		$type = $this->sanitize_field_type( $type );

		switch ( $type ) {
			case 'checkbox':
			case 'anonymous_usage_data':
			case 'integration_row':
			case 'number':
			case 'hidden':
				return (int) $value;
			case 'checkbox_group':
			case 'user_role_blocklist':
				if ( ! is_array( $value ) ) {
					$value = [];
				}
				return array_map( 'sanitize_text_field', $value );
			case 'email':
				return sanitize_email( $value );
			case 'color_picker':
				return sanitize_hex_color( $value );
			case 'ip_blocklist':
				return $this->sanitize_ip_field( $value );
			case 'email_reports':
				return $this->sanitize_email_reports( $value );
			case 'license':
				return defined( 'BURST_PRO' ) && class_exists( '\\Burst\\Pro\\Admin\\Licensing\\Licensing' ) ? ( new \Burst\Pro\Admin\Licensing\Licensing() )->sanitize_license( $value ) : '';
			case 'wysiwyg':
			case 'textarea':
				return wp_kses_post( $value );
			case 'css':
				return $this->sanitize_css( (string) $value );
			default:
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Strictly sanitize a Custom CSS value.
	 *
	 * Allows CSS variables and standard CSS, but removes anything that could load
	 * external resources or break out of the inline <style> element it is rendered in.
	 *
	 * @param string $css The raw CSS.
	 * @return string The sanitized CSS.
	 */
	public function sanitize_css( string $css ): string {
		// Strip CSS comments first so split-token obfuscation (e.g. "@imp/**/ort") cannot bypass the checks below.
		$css = (string) preg_replace( '#/\*.*?\*/#s', '', $css );

		// Strip HTML tag starts (e.g. "</style>", "<script>", "<!--") so the value cannot break out of, or inject markup
		// into, the <style> element it is rendered in. A "<" is only removed when it begins a tag ("<" + letter / "!" / "/"),
		// which preserves the CSS child combinator ">" and range media queries such as "@media (width < 600px)".
		$css = (string) preg_replace( '#<(?=[!/a-z])#i', '', $css );

		$denylist = [
			'#@\s*import#i',
			'#@\s*charset#i',
			'#expression\s*\(#i',
			'#javascript\s*:#i',
			'#vbscript\s*:#i',
			'#behaviou?r\s*:#i',
			'#-moz-binding#i',
		];

		// Strip plain url(...) and the denylisted at-rules/tokens (the common, non-evasive case). \s* guards against
		// whitespace-based evasion. A publicly shared report must never load external resources.
		$css = (string) preg_replace( '#url\s*\([^)]*\)?#i', '', $css );
		$css = (string) preg_replace( $denylist, '', $css );

		// Backstop against CSS escape-sequence evasion: the browser decodes "\75rl(" to "url(" and "@\69mport" to
		// "@import", but the literal token never appears in the raw source, so the strip above misses it. Decode the
		// escapes into a detection-only copy; if any denylisted token or markup reappears, the value is hostile -> drop it.
		$decoded  = $this->decode_css_escapes( $css );
		$patterns = array_merge( $denylist, [ '#url\s*\(#i', '#<\s*[a-z!/]#i' ] );
		foreach ( $patterns as $pattern ) {
			if ( preg_match( $pattern, $decoded ) ) {
				return '';
			}
		}

		return trim( $css );
	}

	/**
	 * Decode CSS escape sequences for detection purposes only.
	 *
	 * @param string $css The CSS to decode.
	 * @return string The CSS with escape sequences resolved.
	 */
	private function decode_css_escapes( string $css ): string {
		// Unicode escape: "\" + 1-6 hex digits + optional single trailing whitespace -> the code point.
		$css = (string) preg_replace_callback(
			'/\\\\([0-9A-Fa-f]{1,6})\s?/',
			static function ( array $m ): string {
				$code = hexdec( $m[1] );
				if ( $code < 128 ) {
					return chr( $code );
				}
				return function_exists( 'mb_chr' ) ? (string) mb_chr( $code, 'UTF-8' ) : '';
			},
			$css
		);
		// Simple escape: "\" + any other character -> that character (e.g. "\@" -> "@").
		return (string) preg_replace( '/\\\\(.)/s', '$1', $css );
	}

	/**
	 * Sanitize type against list of allowed field types.
	 *
	 * @param string $type The field type to sanitize.
	 * @return string Sanitized field type.
	 */
	public function sanitize_field_type( string $type ): string {
		$types   = $this->allowed_field_types();
		$default = $this->default_field_type();

		if ( in_array( $type, $types, true ) ) {
			return $type;
		}

		return $default;
	}

	/**
	 * Sanitize a status.
	 *
	 * @param string $status The status to sanitize.
	 * @return string Sanitized status.
	 */
	public function sanitize_status( string $status ): string {
		$statuses = $this->allowed_goal_statuses();
		$default  = $this->default_status();

		if ( in_array( $status, $statuses, true ) ) {
			return $status;
		}

		return $default;
	}

	/**
	 * Sanitize an interval string.
	 *
	 * @param string $interval The interval to sanitize.
	 * @return string Sanitized interval.
	 */
	public function sanitize_interval( string $interval ): string {
		$intervals = $this->allowed_intervals();
		$default   = $this->default_interval();

		if ( in_array( $interval, $intervals, true ) ) {
			return $interval;
		}

		return $default;
	}

	/**
	 * Sanitize a goal metric.
	 *
	 * @param string $metric The goal metric to sanitize.
	 * @return string Sanitized goal metric.
	 */
	public function sanitize_goal_conversion_metric( string $metric ): string {
		$metrics = $this->allowed_goal_metrics();
		$default = $this->default_goal_metric();

		if ( in_array( $metric, $metrics, true ) ) {
			return $metric;
		}

		return $default;
	}

	/**
	 * Sanitize archive status.
	 *
	 * @param string $status The archive status to sanitize.
	 * @return string Sanitized archive status.
	 */
	public function sanitize_archive_status( string $status ): string {
		$statuses = $this->allowed_archive_statuses();
		$default  = $this->default_archive_status();

		if ( in_array( $status, $statuses, true ) ) {
			return $status;
		}

		return $default;
	}

	/**
	 * Sanitize lookup table type.
	 *
	 * @param string $type The lookup table type to sanitize.
	 * @return string Sanitized lookup table type.
	 */
	public function sanitize_lookup_table_type( string $type ): string {
		$types   = $this->allowed_lookup_table_types();
		$default = $this->default_lookup_table_type();

		if ( in_array( $type, $types, true ) ) {
			return $type;
		}

		return $default;
	}

	/**
	 * Sanitize a collection of email reports.
	 *
	 * @param array $email_reports Array of email reports to sanitize.
	 * @return array<array<string, mixed>> Sanitized email reports.
	 */
	public function sanitize_email_reports( array $email_reports ): array {
		$sanitized_email_reports = [];
		foreach ( $email_reports as $report ) {
			if ( ! isset( $report['email'] ) ) {
				continue;
			}

			$sanitized_report          = [];
			$sanitized_report['email'] = sanitize_email( $report['email'] );
			if ( isset( $report['frequency'] ) ) {
				$sanitized_report['frequency'] = $this->sanitize_interval( $report['frequency'] );
			}
			$sanitized_email_reports[] = $sanitized_report;
		}

		return $sanitized_email_reports;
	}

	/**
	 * Sanitize and destructure a URL.
	 *
	 * Ensures the URL is safe and valid, then extracts its components.
	 *
	 * @param string|null $url The input URL.
	 * @return array{
	 *     scheme: string,
	 *     host: string,
	 *     path: string,
	 *     parameters: string
	 * }
	 */
	public function sanitize_url( ?string $url ): array {
		$url_destructured = [
			'scheme'     => 'https',
			'host'       => '',
			'path'       => '',
			'parameters' => '',
		];

		if ( empty( $url ) ) {
			return $url_destructured;
		}

		if ( ! function_exists( 'wp_kses_bad_protocol' ) ) {
			require_once ABSPATH . '/wp-includes/kses.php';
		}

		$sanitized_url = filter_var( $url, FILTER_SANITIZE_URL );
		// Validate the URL.
		if ( ! filter_var( $sanitized_url, FILTER_VALIDATE_URL ) ) {
			return $url_destructured;
		}

		if ( ! function_exists( 'wp_parse_url' ) ) {
			require_once ABSPATH . '/wp-includes/http.php';
		}
		$url = wp_parse_url( esc_url_raw( $sanitized_url ) );
		if ( isset( $url['host'] ) ) {
			$path                            = $url['path'] ?? '';
			$url_destructured['host']        = $url['host'];
			$url_destructured['scheme']      = $url['scheme'];
			$url_destructured['path']        = trailingslashit( $path );
			$url_destructured['parameters']  = $url['query'] ?? '';
			$url_destructured['parameters'] .= $url['fragment'] ?? '';
		}
		return $url_destructured;
	}

	/**
	 * Sanitize a user ID.
	 *
	 * @param string|null $uid User ID to sanitize.
	 * @return string Sanitized user ID.
	 */
	public function sanitize_uid( ?string $uid ): string {
		if ( $uid === null || strlen( $uid ) === 0 || ! preg_match( '/^[a-z0-9-]+$/', $uid ) ) {
			return '';
		}

		return $uid;
	}

	/**
	 * Sanitize a fingerprint.
	 *
	 * @param string|null $fingerprint Fingerprint to sanitize.
	 * @return string Sanitized fingerprint.
	 */
	public function sanitize_fingerprint( ?string $fingerprint ): string {
		if ( $fingerprint === null || strlen( $fingerprint ) === 0 || ! preg_match( '/^[a-z0-9-]+$/', $fingerprint ) ) {
			return '';
		}

		return str_starts_with( $fingerprint, 'f-' ) ? $fingerprint : 'f-' . $fingerprint;
	}

	/**
	 * Sanitize a referrer URL.
	 *
	 * @param string|null $referrer Referrer URL to sanitize.
	 * @return string Sanitized referrer URL.
	 */
	public function sanitize_referrer( ?string $referrer ): string {
		if ( empty( $referrer ) ) {
			return '';
		}

		if ( ! defined( 'BURST_PATH' ) ) {
			$dir     = plugin_dir_path( __FILE__ );
			$src_pos = strpos( $dir, '/includes/' );
			$dir     = $src_pos !== false ? substr( $dir, 0, $src_pos + 1 ) : $dir;
			define( 'BURST_PATH', $dir );
		}
		$referrer = filter_var( $referrer, FILTER_SANITIZE_URL );
		// we use parse_url instead of wp_parse_url so we don't need to load a wp file here.
        //phpcs:ignore
        $referrer_host = parse_url( $referrer, PHP_URL_HOST );
		// No referrer header = direct traffic.
		if ( empty( $referrer_host ) ) {
			return '';
		}

		// Sanitize current host.
		if ( isset( $_SERVER['HTTP_HOST'] ) ) {
			$current_host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) );
		} elseif ( isset( $_SERVER['SERVER_NAME'] ) ) {
			$current_host = sanitize_text_field( wp_unslash( $_SERVER['SERVER_NAME'] ) );
		} else {
			// we use parse_url so we don't need to load a wp file here.
            //phpcs:ignore
			$current_host = parse_url( site_url(), PHP_URL_HOST );
		}

		$current_host  = preg_replace( '/^www\./i', '', $current_host );
		$referrer_host = preg_replace( '/^www\./i', '', $referrer_host );

		// don't track if referrer is the same as current host.
		// if referrer_url starts with current_host, then it is not a referrer.
		if ( str_starts_with( $referrer_host, $current_host ) ) {
			return '';
		}

		$ref_spam_list = file( BURST_PATH . 'lib/vendor/matomo/referrer-spam-list/spammers.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
		$ref_spam_list = apply_filters( 'burst_referrer_spam_list', $ref_spam_list );
		if ( array_search( $referrer_host, $ref_spam_list, true ) ) {
			return 'spammer';
		}

		return untrailingslashit( $referrer_host );
	}

	/**
	 * Sanitize time on page.
	 *
	 * @param string|null $time_on_page Time on page to sanitize.
	 * @return int Sanitized time on page.
	 */
	public function sanitize_time_on_page( ?string $time_on_page ): int {
		return (int) $time_on_page;
	}

	/**
	 * Configuration file for validation rules
	 * These values define valid options for various fields throughout the application
	 * All values can be filtered using WordPress hooks
	 */

	/**
	 * Allowed goal statuses
	 *
	 * @return array<int, string> List of valid goal statuses
	 */
	public function allowed_goal_statuses(): array {
		return apply_filters(
			'burst_allowed_goal_statuses',
			[
				'all',
				'active',
				'inactive',
				'archived',
			]
		);
	}

	/**
	 * Default status (fallback when an invalid status is provided)
	 *
	 * @return string Default status
	 */
	public function default_status(): string {
		return apply_filters( 'burst_default_status', 'inactive' );
	}

	/**
	 * Allowed field types
	 *
	 * @return array<int, string> List of valid field types
	 */
	public function allowed_field_types(): array {
		return apply_filters(
			'burst_allowed_field_types',
			[
				'hidden',
				'database',
				'checkbox',
				'radio',
				'text',
				'textarea',
				'number',
				'email',
				'select',
				'ip_blocklist',
				'email_reports',
				'user_role_blocklist',
				'checkbox_group',
				'license',
				'anonymous_usage_data',
				'css',
				'integration_row',
				'integrations_intro',
			]
		);
	}

	/**
	 * Default field type (fallback when an invalid field type is provided)
	 *
	 * @return string Default field type
	 */
	public function default_field_type(): string {
		return apply_filters( 'burst_default_field_type', 'checkbox' );
	}

	/**
	 * Allowed interval types
	 *
	 * @return array<int, string> List of valid interval types
	 */
	public function allowed_intervals(): array {
		return apply_filters(
			'burst_allowed_intervals',
			[
				'hour',
				'day',
				'week',
				'month',
			]
		);
	}

	/**
	 * Default interval (fallback when an invalid interval is provided)
	 *
	 * @return string Default interval
	 */
	public function default_interval(): string {
		return apply_filters( 'burst_default_interval', 'day' );
	}

	/**
	 * Allowed goal metric types
	 *
	 * @return array<int, string> List of valid goal metric names
	 */
	public function allowed_goal_metrics(): array {
		return apply_filters(
			'burst_allowed_goal_metrics',
			[
				'pageviews',
				'visitors',
				'sessions',
			]
		);
	}

	/**
	 * Default goal metric (fallback when an invalid goal metric is provided)
	 *
	 * @return string Default goal metric
	 */
	public function default_goal_metric(): string {
		return apply_filters( 'burst_default_goal_metric', 'pageviews' );
	}

	/**
	 * Allowed archive statuses
	 *
	 * @return array<int, string> List of valid archive statuses
	 */
	public function allowed_archive_statuses(): array {
		return apply_filters(
			'burst_allowed_archive_statuses',
			[
				'archived',
				'restored',
				'archiving',
				'deleted',
			]
		);
	}

	/**
	 * Default archive status
	 *
	 * @return string Default archive status
	 */
	public function default_archive_status(): string {
		return apply_filters( 'burst_default_archive_status', 'archived' );
	}

	/**
	 * Allowed lookup table types
	 *
	 * @return array<int, string> List of valid lookup table types
	 */
	public function allowed_lookup_table_types(): array {
		return apply_filters(
			'burst_allowed_lookup_table_types',
			[
				'browser',
				'browser_version',
				'device',
				'platform',
			]
		);
	}

	/**
	 * Default lookup table type
	 *
	 * @return string Default lookup table type
	 */
	public function default_lookup_table_type(): string {
		return apply_filters( 'burst_default_lookup_table_type', 'browser' );
	}

	/**
	 * Configuration for filter validation
	 *
	 * @return array<string, array<string, string>> Filter validation configuration
	 */
	public function filter_validation_config(): array {
		return apply_filters(
			'burst_filter_validation_config',
			[
				'goal_id'     => [
					'sanitize' => 'absint',
					'type'     => 'int',
				],
				'bounces'     => [
					'sanitize' => [ $this, 'sanitize_include_exclude' ],
					'type'     => 'string',
				],
				'page_id'     => [
					'sanitize' => 'absint',
					'type'     => 'int',
				],
				'page_url'    => [
					'sanitize' => 'sanitize_text_field',
					'type'     => 'string',
				],
				'referrer'    => [
					'sanitize' => 'sanitize_text_field',
					'type'     => 'string',
				],
				'device'      => [
					'sanitize' => [ $this, 'sanitize_device_filter' ],
					'type'     => 'string',
				],
				'new_visitor' => [
					'sanitize' => [ $this, 'sanitize_include_exclude' ],
					'type'     => 'string',
				],
			]
		);
	}

	/**
	 * Sanitize device filter value
	 *
	 * @param string $device Device value to sanitize.
	 * @return string Sanitized device value
	 */
	public function sanitize_device_filter( string $device ): string {
		$allowed_devices = [ 'desktop', 'tablet', 'mobile', 'other' ];

		if ( in_array( $device, $allowed_devices, true ) ) {
			return $device;
		}

		return '';
	}

	/**
	 * Sanitize include or exclude values
	 *
	 * @param string $value value to sanitize.
	 * @return string Sanitized value
	 */
	public function sanitize_include_exclude( string $value ): string {
		$allowed = [ 'include', 'exclude' ];

		if ( in_array( $value, $allowed, true ) ) {
			return $value;
		}

		return 'exclude';
	}

	/**
	 * Sanitize and convert a boolean value.
	 *
	 * @param string $value The value to sanitize.
	 * @return bool Sanitized boolean value.
	 */
	public function sanitize_and_convert_boolean( string $value ): bool {
		if ( $value === 'include' ) {
			return true;
		}
		if ( $value === 'exclude' ) {
			return false;
		}
		return false;
	}

	/**
	 * Allowed goal types
	 *
	 * This function returns an array of allowed goal types
	 * It uses the goal_fields configuration to extract valid goal types
	 *
	 * @return array<int, string> List of valid goal types
	 */
	public function allowed_goal_types(): array {
		// Get the goal types from the goal_fields array, which is dynamically built.
		// This ensures we always have the most up-to-date list.
		$fields = require_once __DIR__ . '/goal-fields.php';

		// Find the type field in the goal fields.
		$type_field = array_filter(
			$fields,
			static function ( $goal ) {
				return isset( $goal['id'] ) && $goal['id'] === 'type';
			}
		);

		$type_field = reset( $type_field );
		$goal_types = isset( $type_field['options'] ) ? array_keys( $type_field['options'] ) : [ 'clicks' ];

		return apply_filters( 'burst_allowed_goal_types', $goal_types );
	}

	/**
	 * Default goal type (fallback when an invalid goal type is provided)
	 *
	 * @return string Default goal type
	 */
	public function default_goal_type(): string {
		return apply_filters( 'burst_default_goal_type', 'clicks' );
	}

	/**
	 * Get available arguments for a specific data type
	 *
	 * @param string $type The data type for which to get available arguments.
	 * @return array<int, string> List of available arguments for the specified data type
	 */
	public function get_data_available_args( string $type ): array {
		$default_args = [ 'filters', 'metrics', 'group_by', 'goal_id', 'date_start', 'date_end', 'compare_mode', 'compare_date_start', 'compare_date_end' ];

		// Allow filtering of available args by type.
		return apply_filters( 'burst_get_data_available_args', $default_args, $type );
	}

	/**
	 * Sanitize date string for use in date queries.
	 *
	 * @param string|null $date Date string to sanitize (expected format: Y-m-d).
	 * @return string Sanitized date string in Y-m-d format or empty string if invalid.
	 */
	public function normalize_date( ?string $date ): string {
		if ( empty( $date ) ) {
			return '';
		}

		// Remove any non-date characters and trim.
		$date = trim( sanitize_text_field( $date ) );

		// Try to create DateTime object from Y-m-d format.
		$datetime = \DateTime::createFromFormat( 'Y-m-d H:i:s', $date, wp_timezone() );
		// Check if the date was parsed successfully and matches the input exactly.
		if ( ! $datetime || $datetime->format( 'Y-m-d H:i:s' ) !== $date ) {
			return '';
		}

		// Return the unix timestamp as string instead of int.
		return (string) self::convert_date_to_unix( $datetime->format( 'Y-m-d H:i:s' ) );
	}
}
