<?php
/**
 * Common helpers for notifier implementations.
 *
 * @package Backreload\Notifiers
 */

namespace Backreload\Notifiers;

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

abstract class AbstractNotifier implements Notifier {

	public static function description(): string { return ''; }

	/**
	 * Default validation: enforce required + sanitize per schema.
	 */
	public function validate( array $config ): array {
		$errors = [];
		$out    = [];
		foreach ( static::config_schema() as $field ) {
			$key   = $field['key'];
			$value = $config[ $key ] ?? $field['default'] ?? null;

			if ( ! empty( $field['required'] ) && ( null === $value || '' === $value ) ) {
				$errors[ $key ] = sprintf( '%s is required.', $field['label'] );
				continue;
			}
			switch ( $field['type'] ?? 'text' ) {
				case 'int':    $value = (int) $value;            break;
				case 'bool':   $value = (bool) $value;           break;
				default:       $value = is_string( $value ) ? sanitize_text_field( $value ) : $value;
			}
			// SSRF + URL-format guard for any webhook/URL field. Centralised here
			// so EVERY notifier (Slack, Discord, Teams, Google Chat, Webhook) is
			// covered — previously only the generic Webhook notifier checked, and
			// the others sent an admin-supplied URL straight to wp_remote_post.
			if ( self::is_url_field( $field ) && is_string( $value ) && '' !== $value ) {
				$reason = self::url_rejection_reason( $value );
				if ( null !== $reason ) {
					$errors[ $key ] = $reason;
					continue;
				}
			}
			$out[ $key ] = $value;
		}
		if ( $errors ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- $errors is a field=>message validation map consumed as structured JSON by the REST controller, never echoed.
			throw new NotifierValidationException( $errors );
		}
		return $out;
	}

	/** A field whose value is an outbound URL that must pass the SSRF guard. */
	protected static function is_url_field( array $field ): bool {
		$key = strtolower( (string) ( $field['key'] ?? '' ) );
		return in_array( $key, [ 'url', 'webhook_url' ], true )
			|| 'url' === ( $field['type'] ?? '' );
	}

	/**
	 * Validate a URL's format and reject SSRF targets.
	 *
	 * @return string|null  Reason the URL is unacceptable, or null if it's fine.
	 */
	protected static function url_rejection_reason( string $url ): ?string {
		if ( ! filter_var( $url, FILTER_VALIDATE_URL ) || ! preg_match( '#^https?://#i', $url ) ) {
			return 'Enter a valid http(s) URL.';
		}
		return \Backreload\Core\Ssrf::unsafe_url_reason( $url );
	}

	/**
	 * Convenience HTTP POST helper that all webhook-based notifiers can use.
	 *
	 * @param string              $url
	 * @param array<string,mixed> $body  JSON-encoded payload.
	 * @param array<string,string> $headers
	 */
	protected function post_json( string $url, array $body, array $headers = [] ): void {
		self::assert_safe_at_send( $url );
		$res = wp_remote_post( $url, [
			'timeout'   => 15,
			'sslverify' => true,
			'headers'   => array_merge( [ 'Content-Type' => 'application/json' ], $headers ),
			'body'      => wp_json_encode( $body ),
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException(  esc_html( $res->get_error_message() )  );
		}
		$code = wp_remote_retrieve_response_code( $res );
		if ( $code >= 400 ) {
			throw new \RuntimeException(  esc_html( 'HTTP ' . $code . ': ' . self::short_response_body( $res ) )  );
		}
	}

	/**
	 * Trim a wp_remote_* response body to a short, safe excerpt for error
	 * messages. The full body could echo back tokens or internal scan results
	 * (an SSRF-style read primitive), so we cap length and strip non-printables.
	 */
	protected static function short_response_body( $res, int $max = 200 ): string {
		$body = (string) wp_remote_retrieve_body( $res );
		$body = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', $body );
		$body = trim( (string) $body );
		if ( strlen( $body ) > $max ) {
			$body = substr( $body, 0, $max ) . '…';
		}
		return $body;
	}

	/**
	 * Refuse webhook URLs whose host resolves to a private / loopback / link-
	 * local / cloud-metadata address — a notifier that fires `wp_remote_post`
	 * to `http://127.0.0.1/` or `http://169.254.169.254/` is a textbook SSRF.
	 *
	 * Hosts where backups legitimately go to a LAN endpoint (e.g. an internal
	 * Slack mirror) can disable this with the `backreload_allow_private_webhook`
	 * filter, but the default is "block".
	 *
	 * @throws NotifierValidationException
	 */
	protected static function assert_safe_remote_url( string $url ): void {
		// Legacy opt-out kept for back-compat; the shared guard also honours the
		// newer `backreload_allow_private_host` filter.
		if ( apply_filters( 'backreload_allow_private_webhook', false, $url ) ) {
			return;
		}
		$reason = \Backreload\Core\Ssrf::unsafe_url_reason( $url );
		if ( null !== $reason ) {
			throw new NotifierValidationException( [ 'url' => esc_html( $reason ) ] );
		}
	}

	/**
	 * Re-check a URL immediately before dialing it. Validation happens at
	 * save time, but DNS can rebind between then and send time, so the guard
	 * is run again here (closing the TOCTOU / DNS-rebinding window).
	 *
	 * @throws \RuntimeException
	 */
	protected static function assert_safe_at_send( string $url ): void {
		if ( apply_filters( 'backreload_allow_private_webhook', false, $url ) ) {
			return;
		}
		$reason = \Backreload\Core\Ssrf::unsafe_url_reason( $url );
		if ( null !== $reason ) {
			throw new \RuntimeException( esc_html( 'Refusing to send notification: ' . $reason ) );
		}
	}
}
