<?php
/**
 * Engine B: daily provenance snapshot/diff with a strict state machine.
 *
 * States per plugin: ok | closed | not_found | api_error | external | unknown.
 * Transient API failures NEVER produce alerts. Closed payloads have normal
 * fields absent — state transitions are diffed, never "fields disappeared".
 *
 * Honest scope (marketing discipline): change detection + closure alerts +
 * abandonment scoring. NOT backdoor detection.
 *
 * @package Upgrade_Pilot
 */

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

/**
 * Class UPilot_Snapshot
 */
class UPilot_Snapshot {

	const KEEP_PER_SLUG = 10;

	/**
	 * Identity resolver.
	 *
	 * @var UPilot_Identity
	 */
	private $identity;

	/**
	 * API client.
	 *
	 * @var UPilot_WPOrg_Client
	 */
	private $client;

	/**
	 * Compromise list.
	 *
	 * @var UPilot_Compromise_List
	 */
	private $compromise;

	/**
	 * Constructor.
	 *
	 * @param UPilot_Identity        $identity   Identity resolver.
	 * @param UPilot_WPOrg_Client    $client     API client.
	 * @param UPilot_Compromise_List $compromise Compromise list.
	 */
	public function __construct( UPilot_Identity $identity, UPilot_WPOrg_Client $client, UPilot_Compromise_List $compromise ) {
		$this->identity   = $identity;
		$this->client     = $client;
		$this->compromise = $compromise;
	}

	/**
	 * Run the daily snapshot across all wp.org-hosted plugins.
	 *
	 * @param bool $fresh Force fresh API data (bypass 24h cache).
	 * @return array{checked:int,events:int}
	 */
	public function run( $fresh = false, $time_budget = 0 ) {
		$map     = $this->identity->get_map();
		$checked = 0;
		$events  = 0;

		// Collect every wp.org slug first, then fetch them in ONE batched call.
		// This used to be one HTTP request per plugin, which on a 40-plugin site
		// meant 40 requests a day to api.wordpress.org — and 40 per subsite on a
		// network. It is now a single request for the whole network.
		$slugs  = array();
		$names  = array();
		foreach ( $map as $file => $entry ) {
			if ( 'wporg' !== $entry['class'] ) {
				continue;
			}
			$slugs[ $entry['slug'] ]  = $entry['slug'];
			$names[ $entry['slug'] ]  = $entry['name'];
		}

		if ( ! empty( $slugs ) ) {
			// A time budget only applies to the browser-driven "Check now" path.
			// It is expressed as an HTTP-request budget: with batching, even a
			// large site needs only a couple of requests.
			if ( $time_budget > 0 ) {
				$this->client->set_http_budget( max( 1, (int) ceil( $time_budget / 6 ) ) );
			}

			$infos = $this->client->get_plugins_info( array_values( $slugs ), $fresh );

			if ( $time_budget > 0 ) {
				$this->client->set_http_budget( null );
			}

			foreach ( $slugs as $slug ) {
				if ( ! isset( $infos[ $slug ] ) ) {
					continue;
				}
				$checked++;
				$events += $this->snapshot_one( $slug, $names[ $slug ], $infos[ $slug ] );
			}
		}

		// Compromise-list pass (bundled JSON; slugs of known-compromised plugins/versions).
		foreach ( $map as $file => $entry ) {
			$hit = $this->compromise->check( $entry['slug'], $entry['version'] );
			if ( $hit ) {
				$events += $this->record_event_once(
					$entry['slug'],
					'compromise',
					'critical',
					array(
						'name'   => $entry['name'],
						'detail' => $hit,
					)
				);
			}
		}

		UPilot_Store::update_option( 'upilot_last_snapshot_run', time() );

		return array(
			'checked' => $checked,
			'events'  => $events,
		);
	}

	/**
	 * Snapshot a single plugin and diff against the previous state.
	 *
	 * @param string $slug wp.org slug.
	 * @param string $name Display name.
	 * @param array  $info Already-fetched {state, data} for this slug.
	 * @return int Number of events recorded.
	 */
	private function snapshot_one( $slug, $name, $info ) {
		global $wpdb;

		$state = isset( $info['state'] ) ? $info['state'] : 'api_error';

		if ( 'api_error' === $state ) {
			return 0; // UNKNOWN: no snapshot write, no alert. Never alarm on transport failures.
		}

		$payload = array(
			'state' => $state,
			'data'  => $info['data'],
		);
		$hash    = md5( wp_json_encode( $payload ) );
		$table   = UPilot_Store::table( 'snapshots' );

		$last = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own snapshots table; change detection needs the stored row, not a cache.
			$wpdb->prepare(
				"SELECT id, state, payload_hash, payload FROM {$table} WHERE item_type = 'plugin' AND slug = %s ORDER BY id DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
				$slug
			)
		);

		$events = 0;

		if ( $last && $last->payload_hash === $hash ) {
			// No change: refresh the timestamp of the latest row.
			$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own snapshots table; timestamp refresh, nothing to cache.
				$table,
				array( 'captured_at' => current_time( 'mysql', true ) ),
				array( 'id' => $last->id ),
				array( '%s' ),
				array( '%d' )
			);
			return 0;
		}

		// Persist the new snapshot.
		$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- own snapshots table.
			$table,
			array(
				'item_type'    => 'plugin',
				'slug'         => $slug,
				'captured_at'  => current_time( 'mysql', true ),
				'state'        => $state,
				'payload_hash' => $hash,
				'payload'      => wp_json_encode( $payload ),
			),
			array( '%s', '%s', '%s', '%s', '%s', '%s' )
		);
		$this->prune( $slug );

		if ( ! $last ) {
			// First sighting is the baseline for DIFFS — but an already-closed
			// plugin is exactly the "am I safe?" install trigger: alert immediately.
			if ( 'closed' === $state ) {
				return $this->record_event(
					$slug,
					'closed',
					'critical',
					array(
						'name'        => $name,
						'closed_date' => isset( $info['data']['closed_date'] ) ? $info['data']['closed_date'] : '',
						'reason'      => isset( $info['data']['reason'] ) ? $info['data']['reason'] : '',
					)
				);
			}
			return 0;
		}

		$old = json_decode( (string) $last->payload, true );
		if ( ! is_array( $old ) ) {
			return 0;
		}

		// --- State transitions. ---
		if ( 'closed' === $state && 'closed' !== $last->state ) {
			$reason = isset( $info['data']['reason'] ) ? $info['data']['reason'] : '';
			$events += $this->record_event(
				$slug,
				'closed',
				'critical',
				array(
					'name'        => $name,
					'closed_date' => isset( $info['data']['closed_date'] ) ? $info['data']['closed_date'] : '',
					'reason'      => $reason,
				)
			);
			return $events;
		}
		if ( 'ok' === $state && 'closed' === $last->state ) {
			$events += $this->record_event( $slug, 'reopened', 'info', array( 'name' => $name ) );
		}

		if ( 'ok' !== $state || 'ok' !== $last->state ) {
			return $events; // Field diffs only make sense between two OK snapshots.
		}

		$old_data = isset( $old['data'] ) ? $old['data'] : array();
		$new_data = $info['data'];

		// --- Author change (ownership proxy — named honestly). ---
		$old_author = isset( $old_data['author'] ) ? $old_data['author'] : '';
		$new_author = isset( $new_data['author'] ) ? $new_data['author'] : '';
		if ( '' !== $old_author && $old_author !== $new_author ) {
			$events += $this->record_event(
				$slug,
				'author_change',
				'warning',
				array(
					'name' => $name,
					'from' => $old_author,
					'to'   => $new_author,
				)
			);
		}

		// --- Contributor changes. Null means "field absent from the API response"
		// (degradation) — never diff against it, or a flaky response reads as a
		// mass contributor removal. ---
		$old_c = isset( $old_data['contributors'] ) && is_array( $old_data['contributors'] ) ? $old_data['contributors'] : null;
		$new_c = isset( $new_data['contributors'] ) && is_array( $new_data['contributors'] ) ? $new_data['contributors'] : null;
		if ( null !== $old_c && null !== $new_c && ( ! empty( $old_c ) || ! empty( $new_c ) ) ) {
			$added   = array_values( array_diff( $new_c, $old_c ) );
			$removed = array_values( array_diff( $old_c, $new_c ) );
			if ( ! empty( $added ) ) {
				$events += $this->record_event(
					$slug,
					'contributor_added',
					'warning',
					array(
						'name'  => $name,
						'added' => $added,
					)
				);
			}
			if ( ! empty( $removed ) ) {
				$events += $this->record_event(
					$slug,
					'contributor_removed',
					'info',
					array(
						'name'    => $name,
						'removed' => $removed,
					)
				);
			}
		}

		return $events;
	}

	/**
	 * Latest snapshot state map for the UI.
	 *
	 * @return array<string,array>
	 */
	public function get_latest_states() {
		global $wpdb;
		$table = UPilot_Store::table( 'snapshots' );
		$rows  = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own snapshots table; latest-state read for the admin screen.
			"SELECT s1.slug, s1.state, s1.captured_at, s1.payload FROM {$table} s1 INNER JOIN ( SELECT slug, MAX(id) AS max_id FROM {$table} WHERE item_type = 'plugin' GROUP BY slug ) s2 ON s1.id = s2.max_id", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name, no user input.
			ARRAY_A
		);

		$out = array();
		foreach ( (array) $rows as $row ) {
			$payload = json_decode( (string) $row['payload'], true );
			$out[ $row['slug'] ] = array(
				'state'       => $row['state'],
				'captured_at' => $row['captured_at'],
				'data'        => isset( $payload['data'] ) ? $payload['data'] : array(),
			);
		}
		return $out;
	}

	/**
	 * Recent events for the UI.
	 *
	 * @param int $limit Row cap.
	 * @return array[]
	 */
	public function get_events( $limit = 100 ) {
		global $wpdb;
		$table = UPilot_Store::table( 'events' );
		return (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own events table; recent-events read for the admin screen.
			$wpdb->prepare(
				"SELECT id, created_at, slug, event_type, severity, details, acknowledged FROM {$table} ORDER BY id DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
				max( 1, (int) $limit )
			),
			ARRAY_A
		);
	}

	/**
	 * Record an event and hand it to the alert pipeline.
	 *
	 * @param string $slug       Slug.
	 * @param string $event_type Event type.
	 * @param string $severity   info|warning|critical.
	 * @param array  $details    Details payload.
	 * @return int 1 (recorded).
	 */
	private function record_event( $slug, $event_type, $severity, $details ) {
		global $wpdb;
		$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- own events table.
			UPilot_Store::table( 'events' ),
			array(
				'created_at' => current_time( 'mysql', true ),
				'item_type'  => 'plugin',
				'slug'       => $slug,
				'event_type' => $event_type,
				'severity'   => $severity,
				'details'    => wp_json_encode( $details ),
			),
			array( '%s', '%s', '%s', '%s', '%s', '%s' )
		);

		/**
		 * Fires when a provenance event is recorded.
		 *
		 * @param string $slug       Plugin slug.
		 * @param string $event_type Event type.
		 * @param string $severity   Severity.
		 * @param array  $details    Details.
		 */
		do_action( 'upilot_event_recorded', $slug, $event_type, $severity, $details );

		return 1;
	}

	/**
	 * Record an event only once per (slug, type) — for threshold events like compromise hits.
	 *
	 * @param string $slug       Slug.
	 * @param string $event_type Event type.
	 * @param string $severity   Severity.
	 * @param array  $details    Details.
	 * @return int 0|1
	 */
	private function record_event_once( $slug, $event_type, $severity, $details ) {
		global $wpdb;
		$table  = UPilot_Store::table( 'events' );
		$exists = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own events table; once-only guard needs the live row.
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE slug = %s AND event_type = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
				$slug,
				$event_type
			)
		);
		if ( (int) $exists > 0 ) {
			return 0;
		}
		return $this->record_event( $slug, $event_type, $severity, $details );
	}

	/**
	 * Keep only the last N snapshots per slug.
	 *
	 * @param string $slug Slug.
	 */
	private function prune( $slug ) {
		global $wpdb;
		$table = UPilot_Store::table( 'snapshots' );
		$ids   = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own snapshots table; prune pass reads live ids.
			$wpdb->prepare(
				"SELECT id FROM {$table} WHERE item_type = 'plugin' AND slug = %s ORDER BY id DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
				$slug
			)
		);
		if ( count( $ids ) > self::KEEP_PER_SLUG ) {
			$stale = array_map( 'intval', array_slice( $ids, self::KEEP_PER_SLUG ) );

			// The id list is bound, not concatenated: $holes is a counted run of
			// literal %d. Casting to int was already safe, but a concatenated
			// query string reaching $wpdb->query() trips
			// WordPress.DB.PreparedSQL.NotPrepared, which Plugin Check reports as
			// an ERROR — and an SQL-injection error on a reviewer's screen is not
			// a thing to leave lying around, however benign.
			$holes = implode( ',', array_fill( 0, count( $stale ), '%d' ) );

			// One trailing annotation, not a preceding one: a trailing `phpcs:ignore`
			// overrides any ignore on the line above it. $table is our own table name;
			// $holes is a counted run of literal %d; every id is bound through prepare().
			$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$holes})", $stale ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table; ids bound.
		}
	}
}
