<?php
/**
 * Update Tracker — logs plugin, theme, and core updates to wp_conflict_radar_events.
 *
 * Captures the "before" version at upgrader_pre_install (files not yet replaced),
 * then writes the event at upgrader_process_complete (files now on disk).
 *
 * @package ConflictRadar\Monitor
 */

declare( strict_types=1 );

namespace ConflictRadar\Monitor;

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

class UpdateTracker {

	/**
	 * Pre-update versions, keyed "plugin:<file>", "theme:<stylesheet>", "core".
	 * Populated in captureBefore(), read in the record* methods.
	 *
	 * @var array<string,string>
	 */
	private array $before_versions = array();

	/**
	 * Wire hooks. Called from Plugin::run() on plugins_loaded.
	 * upgrader_pre_install is a FILTER — registered via add_filter so the
	 * return value is preserved (returning a WP_Error there aborts the install).
	 */
	public function register(): void {
		add_filter( 'upgrader_pre_install', array( $this, 'captureBefore' ), 10, 2 );
		add_action( 'upgrader_process_complete', array( $this, 'handleComplete' ), 10, 2 );
	}

	/**
	 * Snapshot the current (old) version before files are overwritten.
	 * Handles all three types. Single updates send type + identifier; bulk
	 * updates send only the identifier — both are covered.
	 *
	 * @param bool|\WP_Error $response   Pass-through filter value. Never altered.
	 * @param array          $hook_extra Upgrader context.
	 * @return bool|\WP_Error
	 */
	public function captureBefore( $response, $hook_extra ) {
		if ( ! is_array( $hook_extra ) ) {
			return $response;
		}

		// Plugin.
		if ( ! empty( $hook_extra['plugin'] ) ) {
			if ( ! function_exists( 'get_plugin_data' ) ) {
				require_once ABSPATH . 'wp-admin/includes/plugin.php';
			}
			$file = $hook_extra['plugin'];
			$path = WP_PLUGIN_DIR . '/' . $file;
			if ( file_exists( $path ) ) {
				$data = get_plugin_data( $path, false, false );
				$this->before_versions[ 'plugin:' . $file ] = (string) ( $data['Version'] ?? '' );
			}
		}

		// Theme. Read at pre_install while the cache still holds the OLD version.
		if ( ! empty( $hook_extra['theme'] ) ) {
			$theme = wp_get_theme( $hook_extra['theme'] );
			if ( $theme->exists() ) {
				$this->before_versions[ 'theme:' . $hook_extra['theme'] ] = (string) $theme->get( 'Version' );
			}
		}

		// Core — old version is still in memory from request bootstrap.
		if ( isset( $hook_extra['type'] ) && 'core' === $hook_extra['type'] ) {
			$this->before_versions['core'] = isset( $GLOBALS['wp_version'] )
				? (string) $GLOBALS['wp_version']
				: '';
		}

		return $response; // Pass-through — upgrader_pre_install is a filter.
	}

	/**
	 * Route the completed update to the right recorder.
	 *
	 * @param mixed $upgrader The WP_Upgrader subclass instance.
	 * @param array $options  Completion context (the run()'s hook_extra).
	 */
	public function handleComplete( $upgrader, $options ): void {
		if ( ! is_array( $options ) ) {
			return;
		}

		// CRITICAL dedup: bulk runs fire this hook once per item from inside
		// run() WITHOUT an 'action' key, then once more at the end WITH
		// action=update and the full plugins/themes array. Gating on 'action'
		// processes only the authoritative final firing — no duplicate rows.
		if ( ( $options['action'] ?? '' ) !== 'update' ) {
			return;
		}

		$type = $options['type'] ?? '';

		if ( 'plugin' === $type ) {
			$files = $options['plugins'] ?? ( isset( $options['plugin'] ) ? array( $options['plugin'] ) : array() );
			foreach ( (array) $files as $file ) {
				if ( '' !== (string) $file ) {
					$this->recordPlugin( (string) $file, $upgrader, $options );
				}
			}
		} elseif ( 'theme' === $type ) {
			$themes = $options['themes'] ?? ( isset( $options['theme'] ) ? array( $options['theme'] ) : array() );
			foreach ( (array) $themes as $stylesheet ) {
				if ( '' !== (string) $stylesheet ) {
					$this->recordTheme( (string) $stylesheet, $upgrader, $options );
				}
			}
		} elseif ( 'core' === $type ) {
			$this->recordCore( $upgrader, $options );
		}
		// 'translation' intentionally ignored.
	}

	/**
	 * Record a plugin update. get_plugin_data() parses fresh (no cache), so
	 * post-update it reads the NEW version reliably.
	 */
	private function recordPlugin( string $file, $upgrader, array $options ): void {
		if ( ! function_exists( 'get_plugin_data' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$path = WP_PLUGIN_DIR . '/' . $file;
		$data = file_exists( $path ) ? get_plugin_data( $path, false, false ) : array();

		$name = ! empty( $data['Name'] ) ? (string) $data['Name'] : $this->slugFromFile( $file );

		$this->insertEvent(
			array(
				'event_type'   => 'plugin_update',
				'plugin_slug'  => $this->slugFromFile( $file ),
				'plugin_name'  => $name,
				'version_from' => $this->before_versions[ 'plugin:' . $file ] ?? '',
				'version_to'   => (string) ( $data['Version'] ?? '' ),
			),
			$upgrader,
			$options
		);
	}

	/**
	 * Record a theme update. wp_get_theme() is cached in WP_Theme, so the cache
	 * MUST be cleared before reading the new version.
	 */
	private function recordTheme( string $stylesheet, $upgrader, array $options ): void {
		wp_clean_themes_cache(); // Bust the WP_Theme cache to read the NEW version.
		$theme = wp_get_theme( $stylesheet );

		$name = $theme->exists() ? (string) $theme->get( 'Name' ) : $stylesheet;
		$new  = $theme->exists() ? (string) $theme->get( 'Version' ) : '';

		$this->insertEvent(
			array(
				'event_type'   => 'theme_update',
				'plugin_slug'  => $stylesheet, // Stylesheet directory acts as the slug.
				'plugin_name'  => $name,
				'version_from' => $this->before_versions[ 'theme:' . $stylesheet ] ?? '',
				'version_to'   => $new,
			),
			$upgrader,
			$options
		);
	}

	/**
	 * Record a core update. Must never fatal — all lookups are guarded.
	 * version_from = in-memory $wp_version (loaded at request start, still old).
	 * version_to   = update_core transient's offered version, with a result fallback.
	 * Per the roadmap, an imperfect/simulated core capture is acceptable.
	 */
	private function recordCore( $upgrader, array $options ): void {
		$from = $this->before_versions['core']
			?? ( isset( $GLOBALS['wp_version'] ) ? (string) $GLOBALS['wp_version'] : '' );

		$to      = '';
		$updates = get_site_transient( 'update_core' );
		if ( isset( $updates->updates[0]->current ) ) {
			$to = (string) $updates->updates[0]->current;
		}
		if ( '' === $to && isset( $upgrader->result['version'] ) ) {
			$to = (string) $upgrader->result['version'];
		}

		$this->insertEvent(
			array(
				'event_type'   => 'core_update',
				'plugin_slug'  => 'wordpress-core',
				'plugin_name'  => 'WordPress Core',
				'version_from' => (string) $from,
				'version_to'   => $to,
			),
			$upgrader,
			$options
		);
	}

	/**
	 * Derive a slug from a plugin file.
	 * "woocommerce/woocommerce.php" → "woocommerce"
	 * "hello.php" (single-file plugin) → "hello"
	 */
	private function slugFromFile( string $file ): string {
		if ( false !== strpos( $file, '/' ) ) {
			return dirname( $file );
		}
		return basename( $file, '.php' );
	}

	/**
	 * Shared insert helper. Resolves manual/automatic, stamps UTC, writes the
	 * row, fires the seam hook.
	 *
	 * @param array $fields   The five event fields.
	 * @param mixed $upgrader Upgrader instance — its skin is inspected to detect auto-updates.
	 * @param array $options  Completion context (unused here, passed for future use).
	 */
	private function insertEvent( array $fields, $upgrader, array $options ): void {
		global $wpdb;

		// Manual vs automatic — decided by POSITIVE unattended signals, never by
		// "is a user present" (WP-CLI and some cron paths still report a user id at
		// hook time). A run is automatic if it is a cron event, a WP-CLI invocation,
		// or driven by Automatic_Upgrader_Skin (WP's background auto-updater).
		// Anything else is a human-initiated admin update.
		$is_auto = wp_doing_cron()
			|| ( defined( 'WP_CLI' ) && \WP_CLI )
			|| ( isset( $upgrader->skin ) && $upgrader->skin instanceof \Automatic_Upgrader_Skin );

		// Live value: 0 in genuine cron/auto contexts, the real id for a logged-in
		// admin. When $is_auto we force 0 so an unattended run is never attributed
		// to whatever user happened to be loaded. No author/admin fallback.
		$user_id = get_current_user_id();

		$row = array_merge(
			$fields,
			array(
				'update_source'     => $is_auto ? 'automatic' : 'manual',
				'triggered_by_user' => $is_auto ? 0 : $user_id,
				'created_at'        => current_time( 'mysql', true ), // true = UTC/GMT.
			)
		);

		// Sequential format list — order matches $row's array_merge output:
		// event_type, plugin_slug, plugin_name, version_from, version_to,
		// update_source, triggered_by_user, created_at.
		// wpdb::insert() consumes $format via array_shift() and ignores keys,
		// so a positional list is the only safe form.
		$formats = array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table (wp_conflict_radar_*); WP options API / object cache not applicable to dedicated event/reporting data.
		$inserted = $wpdb->insert( $wpdb->prefix . 'conflict_radar_events', $row, $formats );

		if ( false === $inserted ) {
			return;
		}

		$event_id = (int) $wpdb->insert_id;

		/**
		 * Seam for later phases. UpdateTracker stays free-tier and Pro-ignorant.
		 * Phase 3 ErrorLogReader::scan() and Phase 6 CorrelationEngine (gated)
		 * subscribe here. snapshot_data / correlation_id are left at their
		 * column defaults — they're Pro P3 / P1.
		 *
		 * @param int   $event_id Inserted row ID.
		 * @param array $row      The data written.
		 */
		do_action( 'conflict_radar_event_recorded', $event_id, $row );
	}
}
