<?php
/**
 * Spam check using denylist
 *
 * @since 6.21
 *
 * @package Formidable
 */

if ( ! defined( 'ABSPATH' ) ) {
	die( 'You are not allowed to call this page directly.' );
}

class FrmSpamCheckDenylist extends FrmSpamCheck {

	const COMPARE_CONTAINS = '';

	const COMPARE_EQUALS = 'equals';

	/**
	 * @var array
	 */
	protected $posted_fields;

	/**
	 * @var array
	 */
	protected $denylist;

	/**
	 * @param array $values
	 */
	public function __construct( $values ) {
		$this->maybe_add_form_id_to_values( $values );

		parent::__construct( $values );

		$this->denylist = $this->get_denylist_array();
	}

	/**
	 * @return array
	 */
	protected function get_posted_fields() {
		if ( is_null( $this->posted_fields ) ) {
			$this->posted_fields = FrmField::get_all_for_form( $this->values['form_id'] );
		}
		return $this->posted_fields;
	}

	/**
	 * Maybe add form ID to values. In file name validation, only item_meta in $values.
	 *
	 * @param array $values Spam check values.
	 *
	 * @return void
	 */
	protected function maybe_add_form_id_to_values( &$values ) {
		if ( ! empty( $values['form_id'] ) || empty( $values['item_meta'] ) ) {
			return;
		}

		$field_id = key( $values['item_meta'] );
		$field    = FrmField::getOne( $field_id );

		if ( $field ) {
			$values['form_id'] = $field->form_id;
		}
	}

	protected function is_enabled() {
		$frm_settings = FrmAppHelper::get_settings();
		$is_enabled   = $frm_settings->denylist_check;

		/**
		 * Allows disabling the denylist check.
		 *
		 * @since 6.21
		 *
		 * @param bool  $is_enabled Whether the denylist check is enabled.
		 * @param array $values     The entry values.
		 */
		return apply_filters( 'frm_check_denylist', $is_enabled, $this->values );
	}

	/**
	 * Gets denylist data.
	 * See {@see FrmSpamCheckDenylist::fill_default_denylist_data()} for more details.
	 *
	 * @return array[]
	 */
	protected function get_denylist_array() {
		$denylist_data = array(
			array(
				'file' => FrmAppHelper::plugin_path() . '/denylist/domain-partial.txt',
			),
			array(
				'file'             => FrmAppHelper::plugin_path() . '/denylist/splorp-wp-comment.txt',
				'skip'             => FrmAppHelper::current_user_can( 'frm_create_entries' ),
				'skip_field_types' => array( 'file' ),
			),
			array(
				'words'       => array(
					'moncler|north face|vuitton|handbag|burberry|outlet|prada|cialis|viagra|maillot|oakley|ralph lauren|ray ban|iphone|プラダ',
				),
				'field_types' => array( 'name' ),
				'is_regex'    => true,
			),
			array(
				'words'       => array(
					'@mail\.ru|@yandex\.',
				),
				'field_types' => array( 'email' ),
				'is_regex'    => true,
			),
		);

		$custom_denylist = $this->get_words_from_setting( 'disallowed_words' );

		if ( $custom_denylist ) {
			$denylist_data['custom'] = array(
				'words' => $custom_denylist,
			);
		}

		/**
		 * Allows to modify the denylist data.
		 *
		 * @since 6.21
		 *
		 * @param array[] $denylist_data The denylist data.
		 */
		return apply_filters( 'frm_denylist_data', $denylist_data );
	}

	/**
	 * Gets denylist IP addresses.
	 *
	 * @return array
	 */
	protected function get_denylist_ips() {
		return apply_filters(
			'frm_denylist_ips_data',
			array(
				'files'  => array(
					FrmAppHelper::plugin_path() . '/denylist/ip.txt',
				),
				'custom' => array(),
			)
		);
	}

	/**
	 * Checks spam.
	 *
	 * @return bool
	 */
	public function check() {
		return $this->check_ip() ? true : $this->check_values();
	}

	/**
	 * Checks entry values.
	 *
	 * @return bool
	 */
	protected function check_values() {
		$allowed_words = $this->get_words_from_setting( 'allowed_words' );
		$allowed_words = array_map( array( $this, 'convert_to_lowercase' ), $allowed_words );

		foreach ( $this->denylist as $denylist ) {
			if ( ! empty( $denylist['skip'] ) ) {
				continue;
			}

			if ( empty( $denylist['file'] ) && empty( $denylist['words'] ) ) {
				continue;
			}

			$this->fill_default_denylist_data( $denylist );
			$denylist['allowed_words'] = $allowed_words;

			if ( ! empty( $denylist['words'] ) ) {
				foreach ( $denylist['words'] as $word ) {
					if ( $this->single_line_check_values( $word, $denylist ) ) {
						self::add_spam_keyword_to_option( $word );
						return true;
					}
				}
			} elseif ( file_exists( $denylist['file'] ) ) {
				$is_spam = $this->read_lines_and_check( $denylist['file'], array( $this, 'single_line_check_values' ), $denylist );

				if ( $is_spam ) {
					return true;
				}
			}
		}//end foreach

		return false;
	}

	/**
	 * Fills default denylist data.
	 *
	 * @param array $denylist Denylist.
	 *
	 * @return void
	 */
	protected function fill_default_denylist_data( &$denylist ) {
		$denylist = wp_parse_args(
			$denylist,
			array(
				'file'             => '',
				'words'            => array(),
				'is_regex'         => false,
				'field_types'      => array(),
				// Add `other` if you want to skip checking Other values of some field types.
				'skip_field_types' => array(),
				// Is ignore if `is_regex` is `true`.
				'compare'          => self::COMPARE_CONTAINS,
				'extract_value'    => '',
				// If this is `true`, this denylist will be skipped.
				'skip'             => false,
			)
		);

		// Some field types should never be checked.
		$denylist['skip_field_types'] = array_merge(
			$denylist['skip_field_types'],
			array( 'password', 'captcha', 'signature', 'checkbox', 'radio', 'select', 'ranking' )
		);
	}

	/**
	 * Gets words from setting.
	 *
	 * @param string $setting_key Setting key.
	 *
	 * @return array
	 */
	protected function get_words_from_setting( $setting_key ) {
		$frm_settings = FrmAppHelper::get_settings();
		$words        = $frm_settings->$setting_key ?? '';

		if ( ! $words ) {
			return array();
		}

		return array_filter(
			array_map( 'trim', explode( "\n", $words ) )
		);
	}

	/**
	 * Checks the values against each single word.
	 *
	 * @param string $line Single line.
	 * @param array  $args Check args.
	 *
	 * @return bool
	 */
	protected function single_line_check_values( $line, $args ) {
		$line = $this->convert_to_lowercase( $line );

		// Do not check if this word is in the allowed words.
		if ( ! empty( $args['allowed_words'] ) && in_array( $line, $args['allowed_words'], true ) ) {
			return false;
		}

		$values_to_check = $this->get_values_to_check( $args );

		if ( ! $values_to_check ) {
			// Nothing needs to be checked.
			return false;
		}

		if ( ! empty( $args['is_regex'] ) ) {
			return preg_match( '/' . trim( $line, '/' ) . '/i', $this->convert_values_to_string( $values_to_check ) );
		}

		if ( self::COMPARE_EQUALS === $args['compare'] ) {
			foreach ( $values_to_check as $value ) {
				$value = $this->convert_to_lowercase( $value );

				if ( $line === $value ) {
					return true;
				}
			}

			return false;
		}

		$values_str = strtolower( $this->convert_values_to_string( $values_to_check ) );
		return str_contains( $values_str, $line );
	}

	/**
	 * Converts values to string to check.
	 *
	 * @param array $values Values array.
	 *
	 * @return string
	 */
	protected function convert_values_to_string( $values ) {
		// Unslash the forward slashes so strings like /joomla/ are not stuck as \/joomla\/.
		return str_replace( '\\/', '/', FrmAppHelper::maybe_json_encode( $values ) );
	}

	/**
	 * Converts string to lowercase.
	 *
	 * @param string $str String.
	 *
	 * @return string
	 */
	protected function convert_to_lowercase( $str ) {
		return strtolower( $str );
	}

	/**
	 * Get the field IDs to check.
	 *
	 * @param array $denylist The denylist data.
	 *
	 * @return array|false Return array of field IDs or false if do not need to check.
	 */
	protected function get_field_ids_to_check( array $denylist ) {
		$field_types      = isset( $denylist['field_types'] ) && is_array( $denylist['field_types'] ) ? $denylist['field_types'] : array();
		$skip_field_types = isset( $denylist['skip_field_types'] ) && is_array( $denylist['skip_field_types'] ) ? $denylist['skip_field_types'] : array();

		if ( ! $field_types && ! $skip_field_types ) {
			// This will check all fields.
			return false;
		}

		$field_ids_to_check = array();

		foreach ( $this->get_posted_fields() as $field ) {
			$field_type = FrmField::get_field_type( $field );

			if ( in_array( $field_type, $skip_field_types, true ) ) {
				continue;
			}

			if ( $field_types && ! in_array( $field_type, $field_types, true ) ) {
				continue;
			}

			$field_ids_to_check[] = intval( $field->id );
		}

		return $field_ids_to_check;
	}

	/**
	 * Gets values to check.
	 *
	 * @param array $denylist Single denylist data.
	 *
	 * @return array|false Return `false` if no values need to check, or return array of values.
	 */
	protected function get_values_to_check( $denylist ) {
		$field_ids_to_check = $this->get_field_ids_to_check( $denylist );

		if ( array() === $field_ids_to_check ) {
			// No values need to check.
			return false;
		}

		$values_to_check = array();

		foreach ( $this->values['item_meta'] as $key => $value ) {
			if ( is_array( $value ) && isset( $value['form'] ) ) {
				// This is a repeater value, loop through sub values.
				unset( $value['form'] );
				unset( $value['row_ids'] );

				foreach ( $value as $sub_key => $sub_value ) {
					if ( $this->should_check_this_field( $sub_key, $field_ids_to_check ) ) {
						$this->add_to_values_to_check( $values_to_check, $sub_value );
					}
				}
			} elseif ( 'other' === $key ) {
				if ( ! in_array( 'other', $denylist['skip_field_types'], true ) ) {
					// This is Other values, loop through this and add sub values.
					foreach ( $value as $sub_value ) {
						$this->add_to_values_to_check( $values_to_check, $sub_value );
					}
				}
			} elseif ( $this->should_check_this_field( $key, $field_ids_to_check ) ) {
				$this->add_to_values_to_check( $values_to_check, $value );
			}
		}//end foreach

		if ( isset( $denylist['extract_value'] ) && is_callable( $denylist['extract_value'] ) ) {
			$values_to_check = call_user_func( $denylist['extract_value'], $values_to_check, $denylist );
		}

		return $values_to_check;
	}

	/**
	 * Checks if should check the value of the given field ID.
	 *
	 * @param int   $field_id           Field ID.
	 * @param int[] $field_ids_to_check Field IDs to check.
	 *
	 * @return bool
	 */
	protected function should_check_this_field( $field_id, $field_ids_to_check ) {
		// Should check this field if no field types is specific or this field ID is in the field IDs to check array.
		return false === $field_ids_to_check || in_array( $field_id, $field_ids_to_check, true );
	}

	/**
	 * Adds the value to values to check array.
	 *
	 * @param array $values_to_check Values to check array.
	 * @param mixed $value           The value.
	 *
	 * @return void
	 */
	protected function add_to_values_to_check( &$values_to_check, $value ) {
		$values_to_check[] = is_array( $value ) ? FrmAppHelper::safe_implode( ' ', $value ) : $value;
	}

	/**
	 * Checks if IP is denied.
	 *
	 * @return bool
	 */
	protected function check_ip() {
		$ip = FrmAppHelper::get_ip_address();

		if ( $this->is_allowed_ip( $ip ) ) {
			return false;
		}

		$denylist_ips = $this->get_denylist_ips();

		if ( ! empty( $denylist_ips['custom'] ) && $this->ip_matches_array( $ip, $denylist_ips['custom'] ) ) {
			return true;
		}

		if ( empty( $denylist_ips['files'] ) || ! is_array( $denylist_ips['files'] ) ) {
			return false;
		}

		foreach ( $denylist_ips['files'] as $file ) {
			if ( ! file_exists( $file ) ) {
				continue;
			}

			$is_spam = $this->read_lines_and_check(
				$file,
				array( $this, 'single_line_check_ip' ),
				compact( 'ip' )
			);

			if ( $is_spam ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Reads lines in file and do the check.
	 *
	 * @param string   $file_path     File path.
	 * @param callable $callback      Check callback.
	 * @param array    $callback_args Callback args.
	 *
	 * @return bool
	 */
	protected function read_lines_and_check( $file_path, $callback, $callback_args = array() ) {
		if ( ! is_callable( $callback ) ) {
			return false;
		}

		$fp = @fopen( $file_path, 'r' );

		if ( ! $fp ) {
			return false;
		}

		while ( ( $line = fgets( $fp ) ) !== false ) {
			$line = trim( $line );

			if ( $line === '' ) {
				continue;
			}

			$is_spam = $callback( $line, $callback_args );

			if ( $is_spam ) {
				if ( is_array( $callback ) && isset( $callback[1] ) && 'single_line_check_values' === $callback[1] ) {
					self::add_spam_keyword_to_option( $line );
				}

				fclose( $fp );
				return true;
			}
		}

		fclose( $fp );
		return false;
	}

	/**
	 * Checks if the given IP is allowed.
	 *
	 * @param string $ip IP address.
	 *
	 * @return bool
	 */
	protected function is_allowed_ip( $ip ) {
		return $this->ip_matches_array( $ip, FrmAntiSpamController::get_allowed_ips() );
	}

	/**
	 * @param string $line
	 * @param array  $args
	 *
	 * @return bool
	 */
	protected function single_line_check_ip( $line, $args ) {
		return $this->ip_matches( $args['ip'], $line );
	}

	/**
	 * Checks if the given IP address matches the IP address with CIDR format.
	 *
	 * @param string $ip      IP address.
	 * @param string $cidr_ip IP address with CIDR format (x.x.x.x/24).
	 *
	 * @return bool
	 */
	protected function ip_matches( $ip, $cidr_ip ) {
		$cidr_parts = explode( '/', $cidr_ip );

		// If the second IP doesn't have CIDR format, just use equals comparison.
		if ( 1 === count( $cidr_parts ) ) {
			return $ip === $cidr_ip;
		}

		if ( str_starts_with( $ip . '/', $cidr_ip ) ) {
			// 1.1.1.1 and 1.1.1.1/24 matches.
			return true;
		}

		// Validate IP address format - only IPv4 is supported in the CIDR check.
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			return false;
		}

		list( $net, $mask ) = explode( '/', $cidr_ip );

		$ip_net  = ip2long( $net );
		$ip_mask = ~( ( 1 << ( 32 - intval( $mask ) ) ) - 1 ); // phpcs:ignore SlevomatCodingStandard.PHP.UselessParentheses.UselessParentheses

		$ip_ip = ip2long( $ip );

		return ( $ip_ip & $ip_mask ) === ( $ip_net & $ip_mask );
	}

	/**
	 * Checks if the given IP matches an IP in the array.
	 *
	 * @param string   $ip       The IP address.
	 * @param string[] $ip_array Array of IP addresses.
	 *
	 * @return bool
	 */
	protected function ip_matches_array( $ip, $ip_array ) {
		foreach ( $ip_array as $cidr_ip ) {
			if ( $this->ip_matches( $ip, $cidr_ip ) ) {
				return true;
			}
		}
		return false;
	}

	protected function get_spam_message() {
		return __( 'Your entry appears to be blocked spam!', 'formidable' );
	}

	/**
	 * @param string $keyword
	 *
	 * @return void
	 */
	private function add_spam_keyword_to_option( $keyword ) {
		$transient_name = 'frm_recent_spam_detected';
		$transient      = get_transient( $transient_name );

		if ( ! is_array( $transient ) ) {
			$transient = array();
		}

		if ( in_array( $keyword, $transient, true ) ) {
			return;
		}

		$transient[] = $keyword;
		set_transient( $transient_name, $transient, DAY_IN_SECONDS );
	}
}
