<?php
/**
 * Verdict assembly with FACT/ADVISORY tiering.
 *
 * Discipline #1: an ADVISORY (static-analysis) finding can NEVER turn the
 * headline verdict red. FACTs (deterministic server/metadata checks) drive RED;
 * advisories drive AMBER. Copy is calibrated: "No blockers found" /
 * "N items need review" / "N confirmed blockers".
 *
 * @package Upgrade_Pilot
 */

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

/**
 * Class UPilot_Verdict
 */
class UPilot_Verdict {

	/**
	 * Components.
	 *
	 * @var UPilot_Server_Check
	 */
	private $server;

	/**
	 * Metadata checker.
	 *
	 * @var UPilot_Metadata_Check
	 */
	private $metadata;

	/**
	 * Scanner.
	 *
	 * @var UPilot_Scanner
	 */
	private $scanner;

	/**
	 * Constructor.
	 *
	 * @param UPilot_Server_Check   $server   Server checks.
	 * @param UPilot_Metadata_Check $metadata Metadata checks.
	 * @param UPilot_Scanner        $scanner  Static scanner.
	 */
	public function __construct( UPilot_Server_Check $server, UPilot_Metadata_Check $metadata, UPilot_Scanner $scanner ) {
		$this->server   = $server;
		$this->metadata = $metadata;
		$this->scanner  = $scanner;
	}

	/**
	 * Assemble the full readiness report.
	 *
	 * @param bool $refresh Force fresh assembly (otherwise cached for an hour).
	 * @return array
	 */
	public function assemble( $refresh = false ) {
		if ( ! $refresh ) {
			$cached = UPilot_Store::get_option( 'upilot_last_report' );
			if ( is_array( $cached ) && isset( $cached['generated_at'] ) && ( time() - $cached['generated_at'] ) < HOUR_IN_SECONDS ) {
				return $cached;
			}
		}

		$settings   = UPilot_Store::get_option( 'upilot_settings', array() );
		$target_php = isset( $settings['target_php'] ) ? $settings['target_php'] : '8.4';

		$server_items = $this->server->run( $target_php );
		$meta         = $this->metadata->run( $target_php );
		$scan         = $this->scanner->get_results( $target_php );
		$scan_state   = $this->scanner->get_state();

		$fact_fails = 0;
		$fact_warns = 0;

		foreach ( $server_items as $item ) {
			if ( 'fail' === $item['status'] ) {
				$fact_fails++;
			} elseif ( 'warn' === $item['status'] ) {
				$fact_warns++;
			}
		}
		$fact_fails += isset( $meta['summary']['fail'] ) ? (int) $meta['summary']['fail'] : 0;
		$fact_warns += isset( $meta['summary']['warn'] ) ? (int) $meta['summary']['warn'] : 0;

		$advisory_errors   = isset( $scan['counts']['error'] ) ? (int) $scan['counts']['error'] : 0;
		$advisory_warnings = isset( $scan['counts']['warning'] ) ? (int) $scan['counts']['warning'] : 0;

		// Verdict: FACT fails => red; anything else notable => amber; else green.
		if ( $fact_fails > 0 ) {
			$verdict  = 'red';
			$headline = sprintf(
				/* translators: %d: number of confirmed blockers. */
				_n( '%d confirmed blocker', '%d confirmed blockers', $fact_fails, 'upgrade-pilot' ),
				$fact_fails
			);
		} elseif ( ( $fact_warns + $advisory_errors + $advisory_warnings ) > 0 ) {
			$review   = $fact_warns + $advisory_errors + $advisory_warnings;
			$verdict  = 'amber';
			$headline = sprintf(
				/* translators: %d: number of review items. */
				_n( '%d item needs review', '%d items need review', $review, 'upgrade-pilot' ),
				$review
			);
		} else {
			$verdict  = 'green';
			$headline = __( 'No blockers found', 'upgrade-pilot' );
		}

		$report = array(
			'generated_at'   => time(),
			'target_php'     => $target_php,
			'verdict'        => $verdict,
			'headline'       => $headline,
			'server_items'   => $server_items,
			'metadata'       => $meta,
			'scan_counts'    => $scan['counts'],
			'scan_state'     => $scan_state,
			'fact_fails'     => $fact_fails,
			'fact_warns'     => $fact_warns,
			'advisory_total' => $advisory_errors + $advisory_warnings,
		);

		UPilot_Store::update_option( 'upilot_last_report', $report, false );
		$this->record_site_status( $report );

		return $report;
	}

	/**
	 * Write this subsite's verdict into the network rollup table.
	 *
	 * Everything else Upgrade Pilot stores is network-wide, because it describes
	 * shared plugin files. This one table is keyed by blog_id, because the one
	 * fact that genuinely varies between subsites is WHICH plugins are active
	 * there — and therefore which of the network's findings actually apply.
	 *
	 * On single-site WordPress this is a single row and costs one upsert. The Pro
	 * network rollup reads these rows instead of recomputing every subsite's
	 * verdict on every page load.
	 *
	 * @param array $report Assembled report.
	 */
	private function record_site_status( $report ) {
		global $wpdb;

		$active = 0;
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		foreach ( array_keys( get_plugins() ) as $file ) {
			if ( is_plugin_active( $file ) ) {
				$active++;
			}
		}

		$criticals = 0;
		$events    = UPilot_Store::table( 'events' );
		$criticals = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$events} WHERE severity = 'critical' AND acknowledged = 0" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table name; single cheap COUNT.

		$wpdb->replace( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own status table; one row per site, refreshed when the report is assembled.
			UPilot_Store::table( 'site_status' ),
			array(
				'blog_id'        => get_current_blog_id(),
				'verdict'        => (string) $report['verdict'],
				'headline'       => (string) $report['headline'],
				'facts'          => (int) $report['fact_fails'],
				'advisories'     => (int) $report['advisory_total'],
				'criticals'      => $criticals,
				'active_plugins' => $active,
				'computed_at'    => current_time( 'mysql', true ),
			),
			array( '%d', '%s', '%s', '%d', '%d', '%d', '%d', '%s' )
		);
	}

	/**
	 * Suppress a finding rule for a plugin (persists across scans).
	 *
	 * @param string $slug    Plugin slug.
	 * @param string $rule_id Rule id, or '*' for the whole plugin.
	 */
	public function suppress( $slug, $rule_id ) {
		$map = UPilot_Store::get_option( 'upilot_suppressed', array() );
		if ( ! is_array( $map ) ) {
			$map = array();
		}
		$map[ sanitize_key( $slug ) . '|' . sanitize_key( $rule_id ) ] = time();
		UPilot_Store::update_option( 'upilot_suppressed', $map, false );
		UPilot_Store::delete_option( 'upilot_last_report' );
	}

	/**
	 * Remove a suppression.
	 *
	 * @param string $slug    Plugin slug.
	 * @param string $rule_id Rule id or '*'.
	 */
	public function unsuppress( $slug, $rule_id ) {
		$map = UPilot_Store::get_option( 'upilot_suppressed', array() );
		if ( is_array( $map ) ) {
			unset( $map[ sanitize_key( $slug ) . '|' . sanitize_key( $rule_id ) ] );
			UPilot_Store::update_option( 'upilot_suppressed', $map, false );
			UPilot_Store::delete_option( 'upilot_last_report' );
		}
	}
}
