<?php

declare(strict_types=1);

namespace SecurWP\Modules\Cloud\Services;

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

use SecurWP\Modules\Firewall\FirewallConfigService;
use SecurWP\Modules\Hardening\Hardening;
use SecurWP\Includes\ServerConfigWriter;

/**
 * SiteHealthScore - Computes a normalized 0-100 security posture score.
 *
 * Used by Console for fleet-wide dashboards.  Pushed as part of
 * the daily state sync via StatePush::push_site_health().
 *
 * Security headers are intentionally excluded from the shared posture model.
 * Header analysis is advisory and must never affect site health on the plugin
 * or in the Console.
 *
 * Rubric before normalization:
 *   Firewall       15 pts
 *   Hardening      15 pts
 *   Scanner        15 pts
 *   2FA            10 pts
 *   Vulnerability  10 pts
 *   Tools          10 pts
 *   Updates        10 pts
 *   Login           5 pts
 */
class SiteHealthScore {

	/**
	 * Maximum points available per scoring category.
	 *
	 * Keep this aligned with the individual score_* methods so posture labels
	 * remain stable even if categories are added or removed.
	 */
	private const BREAKDOWN_MAX_POINTS = [
		'firewall'      => 15,
		'hardening'     => 15,
		'scanner'       => 15,
		'two_factor'    => 10,
		'vulnerability' => 10,
		'tools'         => 10,
		'updates'       => 10,
		'login'         => 5,
	];

	/**
	 * Label ranking used to compare severity posture.
	 *
	 * Higher rank = more severe.
	 */
	private const LABEL_RANK = [
		'Secure'   => 0,
		'Good'     => 1,
		'Fair'     => 2,
		'Poor'     => 3,
		'Critical' => 4,
	];

	/**
	 * Convert a 0-100 score to a health label.
	 *
	 * Labels match the vulnerability severity model used throughout SecurWP:
	 *   85-100 => Secure, 70-84 => Good, 50-69 => Fair, 30-49 => Poor, 0-29 => Critical.
	 *
	 * @param int $score
	 * @return string
	 */
	public static function score_to_label( int $score ): string {
		if ( $score >= 85 ) return 'Secure';
		if ( $score >= 70 ) return 'Good';
		if ( $score >= 50 ) return 'Fair';
		if ( $score >= 30 ) return 'Poor';
		return 'Critical';
	}

	/**
	 * Calculate the security posture score.
	 *
	 * @return array{
	 *   health_label: string,
	 *   posture_label: string,
	 *   scanner_label: string,
	 *   has_scan_baseline: bool,
	 *   scanner_summary: array{
	 *     total_active: int,
	 *     by_severity: array{critical:int,high:int,medium:int,low:int},
	 *     max_severity: string,
	 *     by_type: array<string,int>
	 *   },
	 *   breakdown: array<string, int>,
	 *   issues: string[]
	 * }
	 */
	public static function calculate(): array {
		$breakdown = [];
		$issues    = [];
		$scanner_summary = self::get_active_scanner_summary();
		$has_scan_baseline = (int) \SecurWP\Includes\SettingsStore::get_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'last_scan_time', 0 ) > 0;

		$breakdown['firewall']      = self::score_firewall( $issues );
		$breakdown['hardening']     = self::score_hardening( $issues );
		$breakdown['scanner']       = self::score_scanner( $issues, $scanner_summary, $has_scan_baseline );
		$breakdown['two_factor']    = self::score_two_factor( $issues );
		$breakdown['vulnerability'] = self::score_vulnerability( $issues, $scanner_summary );
		$breakdown['tools']         = self::score_tools( $issues );
		$breakdown['updates']       = self::score_updates( $issues );
		$breakdown['login']         = self::score_login( $issues );

		$score = self::normalize_score( $breakdown );
		$posture_label = self::apply_posture_floors( self::score_to_label( $score ) );
		$scanner_label     = $has_scan_baseline
			? self::severity_to_label( $scanner_summary['max_severity'] )
			: 'Fair';

		if ( ! $has_scan_baseline ) {
			$issues[] = 'No completed scanner baseline available';
		}

		$health_label = self::resolve_health_label( $posture_label, $scanner_label );

		return [
			'health_label' => $health_label,
			'posture_label' => $posture_label,
			'scanner_label' => $scanner_label,
			'has_scan_baseline' => $has_scan_baseline,
			'scanner_summary' => $scanner_summary,
			'breakdown'    => $breakdown,
			'issues'       => $issues,
		];
	}

	/**
	 * Normalize earned rubric points to a canonical 0-100 scale.
	 *
	 * @param array<string,int> $breakdown
	 * @return int
	 */
	private static function normalize_score( array $breakdown ): int {
		$earned_points = max( 0, (int) array_sum( $breakdown ) );
		$max_points    = 0;

		foreach ( array_keys( $breakdown ) as $category ) {
			$max_points += (int) ( self::BREAKDOWN_MAX_POINTS[ $category ] ?? 0 );
		}

		if ( $max_points <= 0 ) {
			return 0;
		}

		return max( 0, min( 100, (int) round( ( $earned_points / $max_points ) * 100 ) ) );
	}

	/**
	 * Resolve final health label.
	 *
	 * Scanner findings are primary. If scanner returns Poor/Critical, posture
	 * checks are intentionally skipped.
	 *
	 * @param string $posture_label
	 * @param string $scanner_label
	 * @return string
	 */
	private static function resolve_health_label( string $posture_label, string $scanner_label ): string {
		if ( in_array( $scanner_label, [ 'Critical', 'Poor' ], true ) ) {
			return $scanner_label;
		}

		return self::max_label( $posture_label, $scanner_label );
	}

	/**
	 * Ensure posture label never understates baseline configuration risk.
	 *
	 * Firewall disabled or zero recommended hardening controls must not render
	 * below Fair.
	 *
	 * @param string $posture_label
	 * @return string
	 */
	private static function apply_posture_floors( string $posture_label ): string {
		$firewall  = \SecurWP\Includes\SettingsStore::get( \SecurWP\Includes\SettingsStore::COL_FIREWALL );
		$hardening = \SecurWP\Includes\SettingsStore::get( \SecurWP\Includes\SettingsStore::COL_HARDENING );

		$firewall_enabled = FirewallConfigService::is_origin_enforcement_active( $firewall );
		$hardening_count  = is_array( $hardening )
			? Hardening::count_effective_hardening_controls( $hardening )
			: 0;

		if ( ! $firewall_enabled || $hardening_count === 0 ) {
			return self::max_label( $posture_label, 'Fair' );
		}

		return $posture_label;
	}

	/**
	 * Convert scanner issue severity to dashboard label scale.
	 *
	 * @param string $severity
	 * @return string
	 */
	private static function severity_to_label( string $severity ): string {
		$normalized = strtolower( trim( $severity ) );
		if ( $normalized === 'critical' ) return 'Critical';
		if ( $normalized === 'high' ) return 'Poor';
		if ( $normalized === 'medium' ) return 'Fair';
		if ( $normalized === 'low' ) return 'Good';
		return 'Secure';
	}

	/**
	 * Return the more severe of two labels.
	 *
	 * @param string $a
	 * @param string $b
	 * @return string
	 */
	private static function max_label( string $a, string $b ): string {
		$a_rank = self::LABEL_RANK[ $a ] ?? self::LABEL_RANK['Fair'];
		$b_rank = self::LABEL_RANK[ $b ] ?? self::LABEL_RANK['Fair'];
		return $a_rank >= $b_rank ? $a : $b;
	}

	/**
	 * Summarize active scanner findings across all finding types.
	 *
	 * Ignored and resolved findings are excluded by design.
	 *
	 * @return array{
	 *   total_active: int,
	 *   by_severity: array{critical:int,high:int,medium:int,low:int},
	 *   max_severity: string,
	 *   by_type: array<string,int>
	 * }
	 */
	private static function get_active_scanner_summary(): array {
		global $wpdb;

		$summary = [
			'total_active' => 0,
			'by_severity'  => [ 'critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0 ],
			'max_severity' => 'none',
			'by_type'      => [],
		];

		$table      = self::table_identifier( $wpdb->prefix . 'securwp_issues' );
		$table_name = trim( $table, '`' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks existence of a plugin-owned summary table before reading aggregates.
		$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table_name ) ) );
		if ( $table_exists !== $table_name ) {
			return $summary;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$severity_rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT LOWER(severity) AS severity, COUNT(*) AS cnt
			 FROM {$table}
			 WHERE is_ignored = 0 AND resolved_at = 0 AND type <> %s
			 GROUP BY LOWER(severity)",
				'system_error'
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		$priority = [ 'critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1 ];
		$max_rank = 0;

		if ( is_array( $severity_rows ) ) {
			foreach ( $severity_rows as $row ) {
				$severity = isset( $row['severity'] ) ? strtolower( (string) $row['severity'] ) : 'low';
				$count    = isset( $row['cnt'] ) ? (int) $row['cnt'] : 0;
				if ( ! isset( $summary['by_severity'][ $severity ] ) ) {
					continue;
				}

				$summary['by_severity'][ $severity ] = $count;
				$summary['total_active']            += $count;

				if ( isset( $priority[ $severity ] ) && $priority[ $severity ] > $max_rank ) {
					$max_rank = $priority[ $severity ];
					$summary['max_severity'] = $severity;
				}
			}
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$type_rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT type, COUNT(*) AS cnt
			 FROM {$table}
			 WHERE is_ignored = 0 AND resolved_at = 0 AND type <> %s
			 GROUP BY type",
				'system_error'
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( is_array( $type_rows ) ) {
			foreach ( $type_rows as $row ) {
				$type  = isset( $row['type'] ) ? (string) $row['type'] : '';
				$count = isset( $row['cnt'] ) ? (int) $row['cnt'] : 0;
				if ( $type !== '' ) {
					$summary['by_type'][ $type ] = $count;
				}
			}
		}

		return $summary;
	}

	/**
	 * Firewall: 15 pts.
	 * Firewall enabled (5), bot and crawler policy enabled (5), rate limiting enabled (5).
	 */
	private static function score_firewall( array &$issues ): int {
		$fw = FirewallConfigService::load_effective_settings();
		$pts = 0;
		$origin_mode = FirewallConfigService::get_origin_enforcement_mode( $fw );

		if ( $origin_mode === FirewallConfigService::MODE_ENFORCE ) {
			$pts += 5;
		} elseif ( ! empty( $fw['enabled'] ) ) {
			$issues[] = 'Firewall WAF is enabled but not enforcing traffic';
		} else {
			$issues[] = 'Firewall WAF is not enabled';
		}

		if ( ! empty( $fw['bot_policy_enabled'] ) ) {
			$pts += 5;
		} else {
			$issues[] = 'Bot and crawler policy is not enabled';
		}

		$rate_enabled = ! empty( $fw['rate_limit_enabled'] );
		if ( $rate_enabled ) {
			$pts += 5;
		} else {
			$issues[] = 'Rate limiting is not enabled';
		}

		return $pts;
	}

	/**
	 * Hardening: 15 pts.
	 * File edit disabled (3), XMLRPC disabled (3), strong passwords (3),
	 * login errors hidden (3), user enum blocked (3).
	 *
	 * When server-layer hardening is enabled but the live site is not
	 * enforcing it, apply a posture penalty and surface that as a site-health
	 * issue so Console and summaries do not overstate the site posture.
	 */
	private static function score_hardening( array &$issues ): int {
		$h = \SecurWP\Includes\SettingsStore::get( \SecurWP\Includes\SettingsStore::COL_HARDENING );
		$pts = 0;

		if ( is_array( $h ) && Hardening::is_hardening_control_effective( $h, 'disable_file_editor' ) ) {
			$pts += 3;
		} else {
			$issues[] = 'WordPress file editor is not disabled';
		}

		if ( is_array( $h ) && Hardening::is_hardening_control_effective( $h, 'disable_xmlrpc' ) ) {
			$pts += 3;
		} else {
			$issues[] = 'XML-RPC remains fully enabled';
		}

		if ( ! empty( $h['enable_strong_passwords'] ) ) {
			$pts += 3;
		} else {
			$issues[] = 'Strong password enforcement is not enabled';
		}

		if ( ! empty( $h['hide_login_errors'] ) ) {
			$pts += 3;
		} else {
			$issues[] = 'Login error messages are not hidden';
		}

		if ( ! empty( $h['block_user_enumeration'] ) ) {
			$pts += 3;
		} else {
			$issues[] = 'User enumeration is not blocked';
		}

		$server_rule_state = ServerConfigWriter::get_state();
		$server_banner = is_array( $server_rule_state['banner'] ?? null ) ? (array) $server_rule_state['banner'] : [];
		if ( ! empty( $server_rule_state['desired_rules_present'] ) && ! empty( $server_banner['show'] ) ) {
			$pts = max( 0, $pts - 3 );
			$issues[] = 'Server hardening protections need attention on the live site';
		}

		return $pts;
	}

	/**
	 * Scanner: 15 pts.
	 * Recent scan <7d (5), zero critical issues (5), zero high issues (5).
	 */
	private static function score_scanner( array &$issues, array $scanner_summary, bool $has_scan_baseline ): int {
		$pts = 0;

		// Check last scan time.
		$last_scan = (int) \SecurWP\Includes\SettingsStore::get_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'last_scan_time', 0 );
		if ( $last_scan > 0 && ( time() - $last_scan ) < ( 7 * DAY_IN_SECONDS ) ) {
			$pts += 5;
		} else {
			$issues[] = 'No scan completed in the last 7 days';
		}

		if ( ! $has_scan_baseline ) {
			return $pts;
		}

		// Check live active issue count from the issue repository summary.
		$issues_count = isset( $scanner_summary['total_active'] ) ? (int) $scanner_summary['total_active'] : 0;
		if ( $issues_count === 0 ) {
			$pts += 10; // Both critical and high = 0.
		} elseif ( $issues_count <= 3 ) {
			$pts += 5; // Minor issues only.
			$issues[] = $issues_count . ' scan issue(s) found';
		} else {
			$issues[] = $issues_count . ' scan issue(s) require attention';
		}

		return $pts;
	}

	/**
	 * 2FA: 10 pts.
	 * Enabled (3), all admins enrolled (4), recovery codes exist (3).
	 */
	private static function score_two_factor( array &$issues ): int {
		$settings = \SecurWP\Includes\SettingsStore::get( \SecurWP\Includes\SettingsStore::COL_TWOFA );
		$pts = 0;

		if ( ! empty( $settings['enabled'] ) ) {
			$pts += 3;
		} else {
			$issues[] = 'Two-factor authentication is not enabled';
			return $pts;
		}

		// Check admin enrollment.
		global $wpdb;
		$table      = self::table_identifier( $wpdb->prefix . 'securwp_two_factor' );
		$table_name = trim( $table, '`' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks existence of a plugin-owned 2FA table before reading enrollment state.
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) === $table_name ) {
			$admin_ids  = get_users( [ 'role' => 'administrator', 'fields' => 'ID' ] );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$enrolled   = $wpdb->get_col( $wpdb->prepare( "SELECT user_id FROM {$table} WHERE is_enabled = %d AND confirmed_at IS NOT NULL", 1 ) );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$all_admins = count( $admin_ids ) > 0 && count( array_diff( $admin_ids, array_map( 'intval', $enrolled ) ) ) === 0;

			if ( $all_admins && count( $admin_ids ) > 0 ) {
				$pts += 4;
			} else {
				$issues[] = 'Not all administrators have 2FA enrolled';
			}

			// Check recovery codes.
			if ( \SecurWP\Modules\TwoFactor\RecoveryCodes::has_codes_for_enabled_users() ) {
				$pts += 3;
			} else {
				$issues[] = 'No users have 2FA recovery codes';
			}
		} else {
			$issues[] = '2FA database table not found';
		}

		return $pts;
	}

	/**
	 * Escape a plugin-owned table identifier for direct interpolation.
	 */
	private static function table_identifier( string $table ): string {
		if ( $table === '' || strlen( $table ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
			throw new \InvalidArgumentException( 'Invalid table identifier.' );
		}

		return '`' . str_replace( '`', '``', $table ) . '`';
	}

	/**
	 * Vulnerability: 10 pts.
	 * Zero critical vulns (5), zero high vulns (3), no abandoned plugins (2).
	 */
	private static function score_vulnerability( array &$issues, array $scanner_summary ): int {
		$pts = 0;

		$scan_issues = isset( $scanner_summary['by_type']['vulnerability'] ) ? (int) $scanner_summary['by_type']['vulnerability'] : 0;
		if ( $scan_issues === 0 ) {
			$pts += 8; // No critical or high vulns.
		} else {
			$issues[] = 'Vulnerability issues detected';
		}

		// Check for abandoned plugins (transient from abandoned checker).
		$abandoned = get_transient( 'securwp_abandoned_plugins' );
		if ( empty( $abandoned ) || ( is_array( $abandoned ) && count( $abandoned ) === 0 ) ) {
			$pts += 2;
		} else {
			$issues[] = 'Abandoned plugins detected';
		}

		return $pts;
	}

	/**
	 * Tools: 10 pts.
	 * DB prefix changed (4), admin ID 1 gone (4), salts < 1yr (2).
	 */
	private static function score_tools( array &$issues ): int {
		global $wpdb;
		$pts = 0;

		if ( $wpdb->prefix !== 'wp_' ) {
			$pts += 4;
		} else {
			$issues[] = 'Default database prefix "wp_" is still in use';
		}

		if ( ! get_user_by( 'id', 1 ) ) {
			$pts += 4;
		} else {
			$issues[] = 'Default admin user ID 1 still exists';
		}

		// Salts age.
		$wp_config = ABSPATH . 'wp-config.php';
		if ( file_exists( $wp_config ) ) {
			$mtime = filemtime( $wp_config );
			if ( $mtime && ( time() - $mtime ) < YEAR_IN_SECONDS ) {
				$pts += 2;
			} else {
				$issues[] = 'Security salts may be older than 1 year';
			}
		}

		return $pts;
	}

	/**
	 * Updates: 10 pts.
	 * WP core up-to-date (4), all plugins updated (3), all themes updated (3).
	 */
	private static function score_updates( array &$issues ): int {
		$pts = 0;

		// Core update check.
		$core_updates = get_core_updates();
		$core_current = true;
		if ( is_array( $core_updates ) && ! empty( $core_updates ) ) {
			$latest = $core_updates[0];
			if ( isset( $latest->response ) && $latest->response !== 'latest' ) {
				$core_current = false;
			}
		}

		if ( $core_current ) {
			$pts += 4;
		} else {
			$issues[] = 'WordPress core is not up to date';
		}

		// Plugin updates.
		$plugin_updates = get_site_transient( 'update_plugins' );
		if ( $plugin_updates && ! empty( $plugin_updates->response ) ) {
			$issues[] = count( $plugin_updates->response ) . ' plugin update(s) available';
		} else {
			$pts += 3;
		}

		// Theme updates.
		$theme_updates = get_site_transient( 'update_themes' );
		if ( $theme_updates && ! empty( $theme_updates->response ) ) {
			$issues[] = count( $theme_updates->response ) . ' theme update(s) available';
		} else {
			$pts += 3;
		}

		return $pts;
	}

	/**
	 * Login: 5 pts.
	 * Login URL renamed (2), CAPTCHA enabled (3).
	 */
	private static function score_login( array &$issues ): int {
		$h = \SecurWP\Includes\SettingsStore::get( \SecurWP\Includes\SettingsStore::COL_HARDENING );
		$pts = 0;

		if ( ! empty( $h['rename_login_url_enabled'] ) && ! empty( $h['rename_login_url_slug'] ) ) {
			$pts += 2;
		} else {
			$issues[] = 'Login URL has not been renamed';
		}

		if ( ! empty( $h['captcha_enabled'] ) ) {
			$pts += 3;
		} else {
			$issues[] = 'Login CAPTCHA is not enabled';
		}

		return $pts;
	}
}
