<?php
/**
 * Heuristic detection for suspicious plugin/theme folder structure.
 *
 * @package WPSecurityNinja\Plugin
 */

namespace WPSecurityNinja\Plugin;

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

/**
 * Class Wf_Sn_Ms_Suspicious_Plugins
 */
class Wf_Sn_Ms_Suspicious_Plugins {

	const CONTEXT_CHECKSUM_FAILED = 'checksum_failed';
	const CONTEXT_ORPHAN          = 'orphan';

	/**
	 * Default score threshold to flag a directory.
	 *
	 * @return int
	 */
	public static function get_threshold() {
		$threshold = apply_filters( 'securityninja_suspicious_plugin_threshold', 3 );
		return max( 1, (int) $threshold );
	}

	/**
	 * Human-readable labels for signal keys.
	 *
	 * @return array<string, string>
	 */
	public static function get_signal_labels() {
		return array(
			'fake_org_uri'      => __( 'Claims wordpress.org but checksum unavailable', 'security-ninja' ),
			'js_php_root'       => __( 'js.php in folder root', 'security-ninja' ),
			'js_php_extension'  => __( 'Double-extension .js.php file', 'security-ninja' ),
			'random_slug'       => __( 'Random-looking folder/slug name', 'security-ninja' ),
			'empty_style_php'   => __( 'Empty style.php file', 'security-ninja' ),
			'minimal_readme'    => __( 'Minimal or fake readme.txt', 'security-ninja' ),
			'orphan_directory'  => __( 'Not a registered plugin or theme', 'security-ninja' ),
		);
	}

	/**
	 * Evaluate a plugin directory when checksum lookup failed.
	 *
	 * @param string $plugin_dir   Absolute path with trailing slash.
	 * @param array  $plugin_data  Plugin header data.
	 * @param string $plugin_slug  Plugin basename slug (directory name).
	 * @return array|null Result row or null if below threshold.
	 */
	public static function evaluate_checksum_failed_plugin( $plugin_dir, $plugin_data, $plugin_slug ) {
		$context = array(
			'context'      => self::CONTEXT_CHECKSUM_FAILED,
			'plugin_data'  => $plugin_data,
			'plugin_slug'  => $plugin_slug,
			'checksum_failed' => true,
		);
		$eval = self::evaluate_directory( $plugin_dir, $context );
		if ( empty( $eval['flagged'] ) ) {
			return null;
		}
		return self::format_result_row( $plugin_dir, $plugin_data, $eval, $plugin_slug );
	}

	/**
	 * Scan orphan directories under plugins and themes.
	 *
	 * @param array $skip_dirs Absolute directory paths already evaluated (normalized, trailing slash).
	 * @return array<string, array> Keyed by directory path.
	 */
	public static function scan_orphan_directories( $skip_dirs = array() ) {
		$results    = array();
		$skip_norm  = array();
		foreach ( $skip_dirs as $dir ) {
			$skip_norm[ wp_normalize_path( trailingslashit( $dir ) ) ] = true;
		}

		$registered_plugin_dirs = self::get_registered_plugin_directories();
		$registered_theme_dirs  = self::get_registered_theme_directories();

		$candidates = array();
		if ( defined( 'WP_PLUGIN_DIR' ) && is_dir( WP_PLUGIN_DIR ) ) {
			$candidates = array_merge( $candidates, self::list_subdirectories( WP_PLUGIN_DIR ) );
		}
		$themes_dir = get_theme_root();
		if ( $themes_dir && is_dir( $themes_dir ) ) {
			$candidates = array_merge( $candidates, self::list_subdirectories( $themes_dir ) );
		}

		foreach ( $candidates as $dir ) {
			$norm = wp_normalize_path( trailingslashit( $dir ) );
			if ( isset( $skip_norm[ $norm ] ) ) {
				continue;
			}
			if ( isset( $registered_plugin_dirs[ $norm ] ) || isset( $registered_theme_dirs[ $norm ] ) ) {
				continue;
			}
			if ( self::is_silence_only_directory( $dir ) ) {
				continue;
			}
			if ( ! self::directory_has_php_files( $dir ) ) {
				continue;
			}

			$context = array(
				'context'     => self::CONTEXT_ORPHAN,
				'plugin_data' => array(
					'Name' => basename( $dir ),
				),
				'plugin_slug' => basename( $dir ),
			);
			$eval = self::evaluate_directory( $dir, $context );
			if ( empty( $eval['flagged'] ) ) {
				continue;
			}
			$key            = 'orphan:' . md5( $norm );
			$results[ $key ] = self::format_result_row( $dir, $context['plugin_data'], $eval, basename( $dir ) );
		}

		return $results;
	}

	/**
	 * Score a directory for suspicious structure.
	 *
	 * @param string $dir     Absolute directory path.
	 * @param array  $context Evaluation context.
	 * @return array{score: int, signals: string[], files: string[], flagged: bool}
	 */
	public static function evaluate_directory( $dir, $context = array() ) {
		$dir = wp_normalize_path( trailingslashit( $dir ) );
		$signals = array();
		$files   = array();

		$plugin_data = isset( $context['plugin_data'] ) && is_array( $context['plugin_data'] ) ? $context['plugin_data'] : array();
		$plugin_slug = isset( $context['plugin_slug'] ) ? (string) $context['plugin_slug'] : basename( untrailingslashit( $dir ) );
		$is_orphan   = isset( $context['context'] ) && self::CONTEXT_ORPHAN === $context['context'];
		$checksum_failed = ! empty( $context['checksum_failed'] ) || $is_orphan;

		if ( $is_orphan ) {
			$signals[] = 'orphan_directory';
		}

		if ( $checksum_failed && self::has_fake_org_uri( $plugin_data, $plugin_slug ) ) {
			$signals[] = 'fake_org_uri';
		}

		if ( is_file( $dir . 'js.php' ) ) {
			$signals[] = 'js_php_root';
			$files[]   = $dir . 'js.php';
		}

		if ( is_file( $dir . 'style.php' ) && 0 === (int) filesize( $dir . 'style.php' ) ) {
			$signals[] = 'empty_style_php';
			$files[]   = $dir . 'style.php';
		}

		$readme_path = $dir . 'readme.txt';
		if ( is_file( $readme_path ) && self::is_minimal_readme( $readme_path ) ) {
			$signals[] = 'minimal_readme';
			$files[]   = $readme_path;
		}

		$js_php_files = self::find_js_php_files( $dir );
		if ( ! empty( $js_php_files ) ) {
			$signals[] = 'js_php_extension';
			$files     = array_merge( $files, $js_php_files );
		}

		if ( self::is_random_slug( $plugin_slug, $plugin_data ) ) {
			if ( $is_orphan || self::has_fake_org_uri( $plugin_data, $plugin_slug ) ) {
				$signals[] = 'random_slug';
			}
		}

		$signals = array_values( array_unique( $signals ) );
		$files   = array_values( array_unique( $files ) );

		$score = self::calculate_score( $signals );
		$score = (int) apply_filters( 'securityninja_suspicious_plugin_signals', $score, $signals, $dir, $context );

		return array(
			'score'   => $score,
			'signals' => $signals,
			'files'   => $files,
			'flagged' => $score >= self::get_threshold(),
		);
	}

	/**
	 * @param string $dir Absolute directory path with trailing slash.
	 * @param array  $plugin_data Plugin header data.
	 * @param array  $eval Evaluation result.
	 * @param string $slug Directory slug.
	 * @return array
	 */
	private static function format_result_row( $dir, $plugin_data, $eval, $slug ) {
		$name = isset( $plugin_data['Name'] ) && $plugin_data['Name'] !== '' ? $plugin_data['Name'] : $slug;
		return array(
			'Name'    => $name,
			'Path'    => wp_normalize_path( trailingslashit( $dir ) ),
			'score'   => $eval['score'],
			'signals' => $eval['signals'],
			'files'   => $eval['files'],
		);
	}

	/**
	 * @param array<string> $signals Signal keys.
	 * @return int
	 */
	private static function calculate_score( $signals ) {
		$points = array(
			'fake_org_uri'     => 2,
			'js_php_root'      => 2,
			'js_php_extension' => 2,
			'random_slug'      => 2,
			'empty_style_php'  => 1,
			'minimal_readme'   => 1,
			'orphan_directory' => 2,
		);
		$score  = 0;
		foreach ( $signals as $signal ) {
			if ( isset( $points[ $signal ] ) ) {
				$score += $points[ $signal ];
			}
		}
		return $score;
	}

	/**
	 * @param array  $plugin_data Plugin header.
	 * @param string $slug Plugin directory slug.
	 * @return bool
	 */
	private static function has_fake_org_uri( $plugin_data, $slug ) {
		if ( empty( $plugin_data['PluginURI'] ) ) {
			return false;
		}
		$uri = strtolower( (string) $plugin_data['PluginURI'] );
		if ( false === strpos( $uri, 'wordpress.org/plugins/' ) ) {
			return false;
		}
		return false !== strpos( $uri, 'wordpress.org/plugins/' . strtolower( $slug ) );
	}

	/**
	 * @param string $slug Plugin slug / folder name.
	 * @param array  $plugin_data Plugin header.
	 * @return bool
	 */
	private static function is_random_slug( $slug, $plugin_data ) {
		$slug = strtolower( (string) $slug );
		if ( strlen( $slug ) < 10 ) {
			return false;
		}
		if ( ! preg_match( '/^[a-z0-9]+$/', $slug ) ) {
			return false;
		}
		if ( false !== strpos( $slug, '-' ) ) {
			return false;
		}
		$name = isset( $plugin_data['Name'] ) ? strtolower( trim( (string) $plugin_data['Name'] ) ) : '';
		if ( $name !== '' && $name !== $slug ) {
			return false;
		}
		return true;
	}

	/**
	 * @param string $readme_path Absolute path to readme.txt.
	 * @return bool
	 */
	private static function is_minimal_readme( $readme_path ) {
		if ( ! is_readable( $readme_path ) ) {
			return false;
		}
		$size = (int) filesize( $readme_path );
		if ( $size < 80 ) {
			return true;
		}
		$content = (string) file_get_contents( $readme_path );
		return false === stripos( $content, 'Stable tag:' );
	}

	/**
	 * Find *.js.php files recursively (not *-js.php).
	 *
	 * @param string $dir Directory with trailing slash.
	 * @return array<int, string>
	 */
	private static function find_js_php_files( $dir ) {
		$found = array();
		if ( ! is_dir( $dir ) ) {
			return $found;
		}
		try {
			$iterator = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS )
			);
		} catch ( \Exception $e ) {
			return $found;
		}
		foreach ( $iterator as $fileinfo ) {
			if ( ! $fileinfo->isFile() ) {
				continue;
			}
			$basename = $fileinfo->getBasename();
			if ( preg_match( '/\.js\.php$/i', $basename ) ) {
				$found[] = wp_normalize_path( $fileinfo->getPathname() );
			}
		}
		return $found;
	}

	/**
	 * @param string $dir Directory path.
	 * @return bool
	 */
	private static function directory_has_php_files( $dir ) {
		$dir = trailingslashit( $dir );
		try {
			$iterator = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS )
			);
		} catch ( \Exception $e ) {
			return false;
		}
		foreach ( $iterator as $fileinfo ) {
			if ( $fileinfo->isFile() && preg_match( '/\.php$/i', $fileinfo->getBasename() ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Whether directory contains only a WordPress silence index.php.
	 *
	 * @param string $dir Directory path.
	 * @return bool
	 */
	private static function is_silence_only_directory( $dir ) {
		$dir = trailingslashit( $dir );
		$has_other = false;
		$entries   = scandir( $dir );
		if ( ! is_array( $entries ) ) {
			return false;
		}
		foreach ( $entries as $entry ) {
			if ( '.' === $entry || '..' === $entry ) {
				continue;
			}
			$path = $dir . $entry;
			if ( is_dir( $path ) ) {
				$has_other = true;
				break;
			}
			if ( 'index.php' !== $entry ) {
				$has_other = true;
				break;
			}
			$content = is_readable( $path ) ? (string) file_get_contents( $path ) : '';
			if ( ! self::is_silence_index_content( $content ) ) {
				$has_other = true;
				break;
			}
		}
		return ! $has_other;
	}

	/**
	 * @param string $content File contents.
	 * @return bool
	 */
	private static function is_silence_index_content( $content ) {
		$normalized = preg_replace( '/\s+/', '', $content );
		return '' === $normalized || '<?php' === $normalized || '<?php//Silenceisgolden.' === strtolower( $normalized );
	}

	/**
	 * @param string $parent Parent directory.
	 * @return array<int, string>
	 */
	private static function list_subdirectories( $parent ) {
		$dirs = array();
		$parent = trailingslashit( wp_normalize_path( $parent ) );
		if ( ! is_dir( $parent ) ) {
			return $dirs;
		}
		$entries = scandir( $parent );
		if ( ! is_array( $entries ) ) {
			return $dirs;
		}
		foreach ( $entries as $entry ) {
			if ( '.' === $entry || '..' === $entry ) {
				continue;
			}
			$path = $parent . $entry;
			if ( is_dir( $path ) ) {
				$dirs[] = $path;
			}
		}
		return $dirs;
	}

	/**
	 * @return array<string, bool> Normalized absolute paths.
	 */
	private static function get_registered_plugin_directories() {
		$map = array();
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		foreach ( array_keys( get_plugins() ) as $basename ) {
			$slug = dirname( $basename );
			if ( '.' === $slug ) {
				continue;
			}
			$path = wp_normalize_path( trailingslashit( WP_PLUGIN_DIR . '/' . $slug ) );
			$map[ $path ] = true;
		}
		return $map;
	}

	/**
	 * @return array<string, bool> Normalized absolute paths.
	 */
	private static function get_registered_theme_directories() {
		$map = array();
		foreach ( wp_get_themes() as $theme ) {
			$path = wp_normalize_path( trailingslashit( $theme->get_stylesheet_directory() ) );
			$map[ $path ] = true;
		}
		return $map;
	}
}
