
<?php
/**
 * Copyright (с) Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2022 All Rights Reserved
 *
 * Licensed under CLOUD LINUX LICENSE AGREEMENT
 * https://www.cloudlinux.com/legal/
 */

namespace CloudLinux\SmartAdvice\App;

use CloudLinux\SmartAdvice;

/**
 * Debug
 */
class Debug {
	/**
	 * Website url.
	 *
	 * @var string
	 */
	private $website = '';

	/**
	 * Home path.
	 *
	 * @var string
	 */
	private $home_path = '';

	/**
	 * User.
	 *
	 * @var string
	 */
	private $user = '';

	/**
	 * Request uri.
	 *
	 * @var string
	 */
	private $request_uri = '';

	/**
	 * Http code.
	 *
	 * @var int
	 */
	private $http_code = 0;

	/**
	 * Server ip.
	 *
	 * @var string
	 */
	private $server_ip = '';

	/**
	 * Error codes.
	 *
	 * @var array<string>
	 *
	 * PHP Core Exceptions
	 */
	public $codes = array(
		E_ERROR             => 'E_ERROR',
		E_WARNING           => 'E_WARNING',
		E_PARSE             => 'E_PARSE',
		E_NOTICE            => 'E_NOTICE',
		E_CORE_ERROR        => 'E_CORE_ERROR',
		E_CORE_WARNING      => 'E_CORE_WARNING',
		E_COMPILE_ERROR     => 'E_COMPILE_ERROR',
		E_COMPILE_WARNING   => 'E_COMPILE_WARNING',
		E_USER_ERROR        => 'E_USER_ERROR',
		E_USER_WARNING      => 'E_USER_WARNING',
		E_USER_NOTICE       => 'E_USER_NOTICE',
		E_STRICT            => 'E_STRICT',
		E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR',
		E_DEPRECATED        => 'E_DEPRECATED',
		E_USER_DEPRECATED   => 'E_USER_DEPRECATED',
		E_ALL               => 'E_ALL',
	);

	/**
	 * Environment.
	 *
	 * @var string
	 */
	private $environment;

	/**
	 * Constructor.
	 *
	 * @param string $environment Environment.
	 */
	public function __construct( $environment ) {
		$this->environment = $environment;
		add_action( 'cl_smart_advice_set_error', array( $this, 'error' ), 10, 5 );
		add_action( 'cl_smart_advice_set_error_handler', array( $this, 'setErrorHandler' ), 10, 0 );
		add_action( 'cl_smart_advice_restore_error_handler', array( $this, 'restoreErrorHandler' ), 10, 0 );
	}

	/**
	 * Get constant home.
	 *
	 * @return string
	 */
	protected function wpHomeConstant() {
		if ( defined( 'WP_HOME' ) && WP_HOME ) {
			return (string) WP_HOME;
		}

		return '';
	}

	/**
	 * Get option home.
	 *
	 * @return string
	 */
	protected function wpHomeOption() {
		if ( function_exists( 'get_option' ) ) {
			$home = get_option( 'home' );
			if ( is_string( $home ) ) {
				return $home;
			}
		}

		return '';
	}

	/**
	 * Get home path.
	 *
	 * @return string
	 */
	public function homePath() {
		if ( ! empty( $this->home_path ) ) {
			return $this->home_path;
		}

		if ( defined( 'ABSPATH' ) ) {
			return ABSPATH;
		}

		return $this->home_path;
	}

	/**
	 * Get website.
	 *
	 * @return string
	 */
	public function website() {
		if ( ! empty( $this->website ) ) {
			return $this->website;
		}

		$wp_home_constant = $this->wpHomeConstant();
		$wp_home_option   = $this->wpHomeOption();

		if ( ! empty( $wp_home_constant ) ) {
			$this->website = $wp_home_constant;
		} elseif ( ! empty( $wp_home_option ) ) {
			$this->website = $wp_home_option;
		} elseif ( is_array( $_SERVER ) && array_key_exists( 'SERVER_NAME', $_SERVER ) ) {
			$this->website = esc_url_raw( wp_unslash( $_SERVER['SERVER_NAME'] ) );
		}

		return $this->website;
	}

	/**
	 * Get user.
	 *
	 * @return string
	 */
	public function user() {
		if ( ! empty( $this->user ) ) {
			return $this->user;
		}

		$parse = wp_parse_url( $this->website() );
		if ( is_array( $parse ) && array_key_exists( 'host', $parse ) ) {
			$this->user = $parse['host'];
		}

		return $this->user;
	}

	/**
	 * Get request uri.
	 *
	 * @return string
	 */
	public function requestUri() {
		if ( ! empty( $this->request_uri ) ) {
			return $this->request_uri;
		}

		if ( is_array( $_SERVER ) && array_key_exists( 'REQUEST_URI', $_SERVER ) ) {
			$raw = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
			// Strip query string and userinfo: query params on public routes (e.g.
			// the unsubscribe token) are bearer secrets that must not leave the site.
			$this->request_uri = $this->strip_uri_secrets( $raw );
		}

		return $this->request_uri;
	}

	/**
	 * Drop the query string and any userinfo from a URI, leaving only the
	 * (optional) scheme/host and path. Used so request URLs reported in
	 * telemetry never carry secrets passed as query parameters.
	 *
	 * @param string $uri Raw request URI (absolute or path-only).
	 *
	 * @return string URI without query string or userinfo.
	 */
	private function strip_uri_secrets( $uri ) {
		$uri = (string) $uri;
		// Drop the query string (and any fragment) wholesale, independent of the
		// length of individual params, so short tokens are dropped too.
		$uri = preg_replace( '/[?#].*$/s', '', $uri );

		$parts = wp_parse_url( $uri );
		if ( ! is_array( $parts ) ) {
			return is_string( $uri ) ? $uri : '';
		}

		// Reassemble scheme://host/path, deliberately omitting user:pass@ userinfo.
		$result = '';
		if ( ! empty( $parts['scheme'] ) && ! empty( $parts['host'] ) ) {
			$result .= $parts['scheme'] . '://';
		}
		if ( ! empty( $parts['host'] ) ) {
			$result .= $parts['host'];
			if ( ! empty( $parts['port'] ) ) {
				$result .= ':' . $parts['port'];
			}
		}
		if ( ! empty( $parts['path'] ) ) {
			$result .= $parts['path'];
		}

		return $result;
	}

	/**
	 * Get http code.
	 *
	 * @return int
	 */
	public function httpCode() {
		if ( ! empty( $this->http_code ) ) {
			return $this->http_code;
		}

		if ( function_exists( 'http_response_code' ) ) {
			$this->http_code = (int) http_response_code();
		}

		return $this->http_code;
	}

	/**
	 * Get current server IP Address.
	 *
	 * @return string
	 */
	public function serverIp() {
		if ( ! empty( $this->server_ip ) ) {
			return $this->server_ip;
		}

		if ( isset( $_SERVER['SERVER_ADDR'] ) ) {
			$ip = filter_var( sanitize_text_field( wp_unslash( $_SERVER['SERVER_ADDR'] ) ), FILTER_VALIDATE_IP );
			if ( is_string( $ip ) ) {
				$this->server_ip = $ip;

				return $this->server_ip;
			}
		}

		if ( function_exists( 'gethostbyname' ) && function_exists( 'gethostname' ) ) {
			$hostname = gethostname();
			if ( is_string( $hostname ) ) {
				$ip = gethostbyname( $hostname );
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					$this->server_ip = $ip;
				}
			}
		}

		return $this->server_ip;
	}

	/**
	 * Set error handler.
	 *
	 * @return void
	 */
	public function setErrorHandler() {
		// @phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
		set_error_handler( array( $this, 'error' ), E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED ); // @phpstan-ignore-line
	}

	/**
	 * Restore error handler.
	 *
	 * @return void
	 */
	public function restoreErrorHandler() {
		restore_error_handler();
	}

	/**
	 * Send error.
	 *
	 * @param int     $errno number.
	 * @param string  $errstr message.
	 * @param ?string $errfile file.
	 * @param ?int    $errline line.
	 * @param array   $extra extra data.
	 *
	 * @return void
	 */
	public function error( $errno, $errstr, $errfile = null, $errline = null, $extra = array() ) {
		if ( in_array( $errno, array( E_DEPRECATED, E_USER_DEPRECATED ) ) ) {
			return;
		}

		$data = $this->data( $errno, $errstr, $errfile, $errline, $extra );
		$this->send( $data );
	}

	/**
	 * Data.
	 *
	 * @param int     $errno number.
	 * @param string  $errstr message.
	 * @param ?string $errfile file.
	 * @param ?int    $errline line.
	 * @param array   $extra extra data.
	 *
	 * @return array
	 */
	public function data( $errno, $errstr, $errfile = null, $errline = null, $extra = array() ) {
		$errstr = $this->redact_string( (string) $errstr );

		// Redact PII/secrets from the WHOLE extra channel before it leaves the
		// site: both the built-in telemetry ($this->extra(), which carries
		// request_uri) and the caller-supplied $extra (see redact()).
		//
		// $this->extra() is listed LAST so the server-derived, already-normalized
		// built-in telemetry WINS the merge over caller-supplied keys of the same
		// name. This matters because the built-in keys are safe-value keys that
		// bypass the token scrubber in redact(): a caller must not be able to
		// override e.g. request_uri (normalized by strip_uri_secrets) with a raw
		// value carrying a query token that would then ship unscrubbed. Caller
		// keys NOT produced by extra() are still merged in and redacted.
		$merged_extra = $this->redact(
			array_merge(
				(array) $extra,
				$this->extra()
			)
		);

		return array(
			'environment'                 => $this->environment,
			'release'                     => $this->release(),
			'tags'                        => $this->tags(),
			'extra'                       => $merged_extra,
			'user'                        => $this->userdata(),
			'sentry.interfaces.Exception' => array(
				'exc_omitted' => null,
				'values'      => array(
					array(
						'stacktrace' => array(
							'frames'         => $this->prepare_stack_frames( $errfile, $errno ),
							'frames_omitted' => null,
						),
						'type'       => isset( $this->codes[ $errno ] ) ? $this->codes[ $errno ] : 'Undefined: ' . $errno,
						'value'      => $errstr,
					),
				),
			),
		);
	}

	/**
	 * Keys whose values are sensitive (PII or bearer capability) and must not
	 * be sent off-site, matched case-insensitively as a substring of the key.
	 *
	 * @var array<string>
	 */
	private $sensitive_keys = array(
		'email',
		'token',
		'password',
		'passwd',
		'secret',
		'authorization',
		'auth',
		'api_key',
		'apikey',
		'access_key',
		'private_key',
	);

	/**
	 * Keys whose values are STRUCTURED, server/enum-derived telemetry -- never
	 * free-form text or visitor-controlled input. Their string values bypass the
	 * redact_string() opaque-token scrubber (the 24+ char run), which would
	 * otherwise mask an enum identifier such as advice_type into '[redacted]'.
	 * Deliberately EXCLUDES visitor-derived keys (request_uri, website,
	 * home_path): those can carry a path-embedded bearer-like token after query
	 * stripping, so they must stay on the full scrub -- under-redacting a path
	 * secret (data exposure) outweighs the rare over-redaction of a 24+ char
	 * slug. Matched case-insensitively as an EXACT key name (unlike
	 * $sensitive_keys, which match as a substring). An exact match here also
	 * EXEMPTS the key from the substring-based sensitive-key removal in redact()
	 * -- e.g. 'email_type' contains the 'email' substring but holds a category
	 * enum ('advices'/'reminders'), not an address, so it must not be masked.
	 * Real address keys ('email', 'user_email', ...) are NOT listed here and so
	 * stay masked.
	 *
	 * @var array<string>
	 */
	private $safe_value_keys = array(
		'http_code',
		'server_ip',
		'advice_type',
		'email_type',
	);

	/**
	 * Redacted-value placeholder.
	 *
	 * @var string
	 */
	private $redaction_mask = '[redacted]';

	/**
	 * Redact sensitive entries from a caller-supplied $extra array.
	 *
	 * Values keyed by anything matching $sensitive_keys are replaced with the
	 * mask; the array is walked recursively so nested payloads are covered.
	 * Scalar string values are additionally scrubbed by redact_string().
	 *
	 * @param array $extra Caller-supplied extra data.
	 *
	 * @return array Redacted extra data.
	 */
	private function redact( $extra ) {
		$result = array();
		foreach ( $extra as $key => $value ) {
			// $safe_value_keys is an exact-match allowlist of structured enum
			// keys; a key on it is exempt from the broad substring sensitive-key
			// removal so e.g. 'email_type' (a category enum) is not masked just
			// because its name contains 'email'. Its value is still PII-scrubbed
			// via redact_pii_in_string() in the safe-value branch below.
			if ( is_string( $key ) && $this->is_sensitive_key( $key )
				&& ! $this->is_safe_value_key( $key ) ) {
				$result[ $key ] = $this->redaction_mask;
				continue;
			}

			if ( is_array( $value ) ) {
				$result[ $key ] = $this->redact( $value );
			} elseif ( is_string( $value ) ) {
				// Structured, server/enum-derived telemetry (advice_type,
				// http_code, server_ip) skips ONLY the broad opaque-token scrub,
				// which would otherwise turn a long enum identifier into
				// '[redacted]' and ship misleading telemetry; email masking is
				// still applied to them as cheap defence in depth. Everything
				// else -- including the visitor-derived request_uri/website/
				// home_path, which can carry a path-embedded token or email after
				// query stripping -- gets the FULL scrub. The sensitive-key
				// removal above applies to all.
				$result[ $key ] = ( is_string( $key ) && $this->is_safe_value_key( $key ) )
					? $this->redact_pii_in_string( $value )
					: $this->redact_string( $value );
			} else {
				$result[ $key ] = $value;
			}
		}

		return $result;
	}

	/**
	 * Whether a key name denotes a sensitive value.
	 *
	 * @param string $key Array key.
	 *
	 * @return bool
	 */
	private function is_sensitive_key( $key ) {
		$key = strtolower( $key );
		foreach ( $this->sensitive_keys as $needle ) {
			if ( false !== strpos( $key, $needle ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Whether a key denotes a structured telemetry value that must bypass the
	 * free-text opaque-token scrubber (see $safe_value_keys).
	 *
	 * @param string $key Array key.
	 *
	 * @return bool
	 */
	private function is_safe_value_key( $key ) {
		return in_array( strtolower( $key ), $this->safe_value_keys, true );
	}

	/**
	 * Redact obvious PII/secrets from a free-form string before it is shipped
	 * off-site (e.g. embedded in $errstr or a string $extra value).
	 *
	 * Masks email addresses and long opaque tokens; leaves ordinary error text
	 * intact so telemetry stays useful.
	 *
	 * @param string $value String to scrub.
	 *
	 * @return string Scrubbed string.
	 */
	private function redact_string( $value ) {
		// Unambiguous PII (always masked, even for safe-value keys).
		$value = $this->redact_pii_in_string( $value );
		// Long opaque tokens (>= 24 chars of base62/url-safe) such as unsubscribe
		// tokens or API keys. This broad rule can also mangle legitimate long
		// route slugs / identifiers, so redact() relaxes ONLY this rule (not the
		// PII rule above) for safe-value keys (see is_safe_value_key()).
		$value = preg_replace( '/\b[A-Za-z0-9_-]{24,}\b/', $this->redaction_mask, $value );

		return is_string( $value ) ? $value : $this->redaction_mask;
	}

	/**
	 * Mask unambiguous PII (email addresses) in a free-form string. This subset
	 * of redact_string() is applied to EVERY value, including safe-value keys: an
	 * email embedded in a request_uri path, website or home_path is PII, never a
	 * benign slug, so it must be scrubbed even when the broad opaque-token rule
	 * is relaxed for structured telemetry.
	 *
	 * @param string $value String to scrub.
	 *
	 * @return string Scrubbed string.
	 */
	private function redact_pii_in_string( $value ) {
		$value = preg_replace( '/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/', $this->redaction_mask, $value );

		return is_string( $value ) ? $value : $this->redaction_mask;
	}

	/**
	 * Send to sentry.
	 *
	 * @param array $body send.
	 *
	 * @return string|false
	 */
	public function send( $body ) {
		if ( ! function_exists( 'curl_init' ) || empty( $body ) || defined( 'IS_TESTING' ) ) {
			return false;
		}

		$url     = 'https://cl.sentry.cloudlinux.com/api/' . $this->project_id() . '/store/';
		$headers = array(
			'Content-Type: application/json',
			'X-Sentry-Auth: Sentry sentry_version=7,sentry_timestamp=' . time() . ',sentry_client=php-curl/1.0,sentry_key=' . $this->key(),
		);

		$ch = curl_init();
		curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
		curl_setopt( $ch, CURLOPT_URL, $url );
		curl_setopt( $ch, CURLOPT_POST, true );
		curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, 'POST' );
		curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode( $body ) );
		curl_setopt( $ch, CURLOPT_TIMEOUT, 1 );
		curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 1 );
		curl_setopt( $ch, CURLOPT_HEADER, 0 );
		curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		$response = curl_exec( $ch );
		curl_close( $ch );

		return $response;
	}

	/**
	 * Minimize an absolute filesystem path before it is shipped off-site.
	 *
	 * Mirrors the profiler sibling's minimizePath (F-17): keep only the
	 * basename when it is plausibly a real source/asset file (so a stack-frame
	 * filename stays useful), otherwise return the literal '<path>' token. A
	 * bare directory/account leaf -- e.g. ABSPATH's '/home/<account>/...' or
	 * a docroot -- is therefore reduced to '<path>', so the OS account name and
	 * filesystem layout never reach Sentry. Telemetry goes to CloudLinux's own
	 * first-party Sentry, so this is defence-in-depth consistency with F-17, not
	 * a fix for a cross-tenant leak.
	 *
	 * @param string|null $path Absolute path.
	 *
	 * @return string|null Basename, '<path>', or the input unchanged when empty.
	 */
	private function minimize_path( $path ) {
		if ( ! is_string( $path ) || '' === $path ) {
			return $path;
		}

		$normalized = trim( str_replace( '\\', '/', $path ), '/' );
		if ( '' === $normalized ) {
			return '<path>';
		}

		$segments           = explode( '/', $normalized );
		$basename           = end( $segments );
		$source_ext_pattern = '/\.(php\d?|phtml|phps|inc|module|install'
			. '|engine|theme|profile|tpl|twig|html?|js|mjs|cjs|jsx|tsx?'
			. '|css|s[ac]ss|less|xml|ya?ml|json)$/i';
		if ( '' === $basename || ! preg_match( $source_ext_pattern, $basename ) ) {
			return '<path>';
		}

		return $basename;
	}

	/**
	 * Prepares stack trace data for Sentry.
	 *
	 * @param string $errfile File name.
	 * @param int    $errline Line number.
	 *
	 * @return array
	 *
	 * @since 0.1-9
	 */
	private function prepare_stack_frames( $errfile = null, $errline = null ) {

		// Default result will contain only the error file and line. The filename
		// is path-minimized so an absolute /home/<account>/... path is not
		// shipped to Sentry (consistency with F-17; see minimize_path()).
		$default = array(
			array(
				'filename' => $this->minimize_path( $errfile ),
				'lineno'   => $errline,
			),
		);

		if ( ! function_exists( 'debug_backtrace' ) ) {
			return $default;
		}

		$backtrace = debug_backtrace(); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace

		// Ignore last couple of frames that are not relevant to the error.
		$frames_to_omit = array(
			'App/Debug.php', // Call to CloudLinux\SmartAdvice\App::prepare_stack_frames().
			'App/Debug.php', // Call to CloudLinux\SmartAdvice\App::data().
			'wp-includes/class-wp-hook.php', // WordPress executing a filter.
			'wp-includes/class-wp-hook.php', // WordPress executing a filter.
			'wp-includes/plugin.php', // Call to apply_filters().
		);

		foreach ( $frames_to_omit as $frame_to_omit ) {
			if ( isset( $backtrace[0]['file'] ) && strpos( $backtrace[0]['file'], $frame_to_omit ) !== false ) {
				array_shift( $backtrace );
			}
		}

		$frames = array();
		while ( ! empty( $backtrace ) ) {
			$frame = array_pop( $backtrace );
			if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
				$frames[] = array(
					'filename' => $this->minimize_path( $frame['file'] ),
					'lineno'   => $frame['line'],
				);
			}
		}

		if ( empty( $frames ) ) {
			return $default;
		}

		return $frames;
	}

	/**
	 * Determines the plugin version.
	 *
	 * @return string Plugin version.
	 *
	 * @since 0.1-11
	 */
	public function plugin_version() {
		return defined( 'CL_SMART_ADVICE_VERSION' ) ? CL_SMART_ADVICE_VERSION : 'dev';
	}

	/**
	 * Get environment name.
	 *
	 * @return string Environment name.
	 *
	 * @since 0.1-11
	 */
	public function environment() {
		return $this->environment;
	}

	/**
	 * Get release name.
	 *
	 * @return string Release name.
	 *
	 * @since 0.1-11
	 */
	public function release() {
		return 'php-smart-advice-plugin@' . $this->plugin_version();
	}

	/**
	 * Get Sentry key.
	 *
	 * @return string Sentry Key.
	 *
	 * @since 0.1-11
	 */
	public function key() {
		return 'ef44dbb648894027a282678f691abc5a';
	}

	/**
	 * Get Sentry project ID.
	 *
	 * @return int Sentry project ID.
	 *
	 * @since 0.1-11
	 */
	public function project_id() {
		return 24;
	}

	/**
	 * Get tags.
	 *
	 * @return array<string> Tags.
	 *
	 * @since 0.1-11
	 */
	public function tags() {
		return array(
			'php_version'    => phpversion(),
			'plugin_version' => $this->plugin_version(),
			'xray_version'   => defined( 'CL_SMART_ADVICE_XRAY_RELEASE' ) ? CL_SMART_ADVICE_XRAY_RELEASE : null,
		);
	}

	/**
	 * Get user data.
	 *
	 * @return array<string> User data.
	 *
	 * @since 0.1-11
	 */
	public function userdata() {
		return array(
			'username' => $this->user(),
		);
	}

	/**
	 * Get extra data.
	 *
	 * @return array<string> Extra data.
	 *
	 * @since 0.1-11
	 */
	public function extra() {
		return array(
			'website'     => $this->website(),
			// home_path is ABSPATH ('/home/<account>/public_html/...'); minimize
			// it so the OS account name / FS layout is not shipped to Sentry
			// (consistency with F-17; redact_string alone left the username).
			'home_path'   => $this->minimize_path( $this->homePath() ),
			'request_uri' => $this->requestUri(),
			'http_code'   => $this->httpCode(),
			'server_ip'   => $this->serverIp(),
		);
	}
