<?php
/**
 * WPMR_Definitions_Trait
 *
 * Generated during Phase 2 restructuring
 * This trait contains methods extracted from the original monolithic wpmr.php
 *
 * @package WP_Malware_Removal
 */

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

}

trait WPMR_Definitions {

	/**
	 * Ensure the plugin has a baseline malware definitions payload stored in settings.
	 *
	 * Call sites:
	 * - Invoked during initialization (see `wpmr.php`) and from admin UI bootstrap.
	 * - Also used as a fallback by `get_definitions()` / `get_definition_version()`.
	 *
	 * Behavior:
	 * - If the `signatures` setting is missing/empty, loads definitions from `wpmr.json`,
	 *   stores them via `update_setting( 'signatures', ... )`, and resets `sig_time` to 0.
	 * - If definitions already exist in settings, this method performs no work.
	 *
	 * Side effects:
	 * - Updates plugin settings (`signatures`, `sig_time`).
	 *
	 * Suggested rename: `ensure_default_definitions_loaded()` or `load_default_definitions_if_missing()`.
	 *
	 * @return array|null Returns the loaded definitions array when it had to seed defaults; otherwise null.
	 */
	function maybe_load_default_definitions() {
		$definitions = $this->get_setting( 'signatures' );
		if ( ! $definitions ) {
			$definitions = file_get_contents( trailingslashit( $this->dir ) . 'wpmr.json' );
			$definitions = json_decode( $definitions, true );
			$update      = $this->update_setting( 'signatures', $definitions );
			$this->update_setting( 'sig_time', 0 );
			return $definitions;
		}
	}

	/**
	 * Retrieve the active malware definitions payload, ordered by severity.
	 *
	 * Call sites:
	 * - File scanner and DB scanner use `get_definitions()['definitions']['files']` and
	 *   `get_definitions()['definitions']['db']` to iterate signatures.
	 *
	 * Behavior:
	 * - Loads definitions from the `signatures` setting; if missing, seeds from `wpmr.json`.
	 * - Removes the top-level version key `v` from the returned structure.
	 * - Re-orders both file and DB definition maps so severe/high signatures are evaluated
	 *   before suspicious ones.
	 *
	 * Suggested rename: `get_ordered_definitions()` or `get_definitions_ordered_by_severity()`.
	 *
	 * @return array<string,mixed> Definitions payload (with `definitions.files` and `definitions.db`), version key removed.
	 */
	function get_definitions() {
		$definitions = $this->get_setting( 'signatures' );
		if ( ! $definitions ) {
			$definitions = $this->maybe_load_default_definitions();
		}
		unset( $definitions['v'] );
		$severe     = array();
		$high       = array();
		$suspicious = array();
		foreach ( $definitions['definitions']['files'] as $definition => $signature ) {
			if ( $signature['severity'] == 'severe' ) {
				$severe[ $definition ] = $definitions['definitions']['files'][ $definition ];
			}
			if ( $signature['severity'] == 'high' ) {
				$high[ $definition ] = $definitions['definitions']['files'][ $definition ];
			}
			if ( $signature['severity'] == 'suspicious' ) {
				$suspicious[ $definition ] = $definitions['definitions']['files'][ $definition ];
			}
		}
		$files      = array_merge( $severe, $high, $suspicious ); // always return definitions in this sequence else suspicious matches are returned first without scanning for severe infections.
		$severe     = array();
		$high       = array();
		$suspicious = array();
		foreach ( $definitions['definitions']['db'] as $definition => $signature ) {
			if ( $signature['severity'] == 'severe' ) {
				$severe[ $definition ] = $definitions['definitions']['db'][ $definition ];
			}
			if ( $signature['severity'] == 'high' ) {
				$high[ $definition ] = $definitions['definitions']['db'][ $definition ];
			}
			if ( $signature['severity'] == 'suspicious' ) {
				$suspicious[ $definition ] = $definitions['definitions']['db'][ $definition ];
			}
		}
		$db                                  = array_merge( $severe, $high, $suspicious );
		$definitions['definitions']['files'] = $files;
		$definitions['definitions']['db']    = $db;
		return $definitions;
	}

	/**
	 * Retrieve malware definitions along with their version.
	 *
	 * This is a convenience wrapper for callers that need both the ordered
	 * definitions map and the active definitions version.
	 *
	 * Notes:
	 * - `get_definitions()` intentionally unsets the top-level `v` key.
	 * - Stateful scan workflows need `v` to record/compare signature versions.
	 *
	 * Suggested rename: `get_definitions_with_version()`.
	 *
	 * @return array{definitions: array<string, mixed>, v: string} Definitions payload with version.
	 */
	function get_stateless_definitions() {
		$definitions = $this->get_definitions();
		$definitions['v'] = $this->get_definition_version();
		return $definitions;
	}

	/**
	 * Get the SaaS-served file whitelist from stored definitions as a hash map for O(1) lookup.
	 *
	 * Reads the `file-whitelist` array from the stored definitions payload (served inside
	 * `payload.signatures.definitions.file-whitelist` by the SaaS definitions endpoint).
	 * Falls back to empty array if definitions are unavailable or the key does not exist
	 * (older definitions batches, first install before first SaaS update).
	 *
	 * Available to all users (free and Advanced Edition). This is a detection-quality
	 * improvement — known-good files whose checksums fail validation are automatically
	 * skipped rather than flagged as false positives.
	 *
	 * @return array<string, true> Hash map of sha256-checksum => true for isset() lookups.
	 */
	function get_file_whitelist() {
		$definitions = $this->get_definitions();
		if ( empty( $definitions['definitions']['file-whitelist'] )
			|| ! is_array( $definitions['definitions']['file-whitelist'] )
		) {
			return array();
		}
		return array_fill_keys( $definitions['definitions']['file-whitelist'], true );
	}

	/**
	 * Count the total number of malware signatures across all definition groups.
	 *
	 * Used for UI/telemetry and WP-CLI diagnostics.
	 *
	 * Suggested rename: `count_definitions()`.
	 *
	 * @return int Total count of signatures.
	 */
	function get_definition_count() {
		$defs  = $this->get_definitions();
		$count = 0;
		while ( count( $defs['definitions'] ) ) {
			$count += count( array_shift( $defs['definitions'] ) );
		}
		return $count;
	}

	/**
	 * Get the current local malware definitions version.
	 *
	 * This reads the `v` key from the stored `signatures` setting (seeding from `wpmr.json`
	 * if needed). Unlike `get_definitions()`, this method intentionally returns the version.
	 *
	 * Suggested rename: `get_definitions_version()`.
	 *
	 * @return string Definitions version string, or empty string if unavailable.
	 */
	function get_definition_version() {
		$sigs = $this->get_setting( 'signatures' );

		if ( empty( $sigs ) ) {
			$sigs = $this->maybe_load_default_definitions();
		}

		if ( is_array( $sigs ) && array_key_exists( 'v', $sigs ) && $sigs['v'] !== '' ) {
			return $sigs['v'];
		}

		return '';
	}

	/**
	 * Get a human-readable "last updated" string for definitions.
	 *
	 * The underlying timestamp is stored in the `sig_time` setting.
	 *
	 * Suggested rename: `get_definitions_last_updated_ago()`.
	 *
	 * @return string Returns 'Never' when no timestamp is stored, otherwise e.g. '2 hours ago'.
	 */
	function get_last_updated_ago() {
		$updated = $this->get_setting( 'sig_time' );
		if ( ! $updated ) {
			return 'Never';
		} else {
			return human_time_diff( gmdate( 'U', $updated ), gmdate( 'U' ) ) . ' ago';
		}
	}

	/**
	 * Fetch the full malware definitions payload from the SaaS backend.
	 *
	 * This performs a `saas_request('saas_update_definitions')` call and returns the raw
	 * response structure used by `update_definitions()` and `update_definitions_cli()`.
	 *
	 * Suggested rename: `fetch_definitions_payload()`.
	 *
	 * @param array{blocking?:bool,timeout?:float|int} $options Request options.
	 * @return array|WP_Error SaaS response array on success, or WP_Error on failure.
	 */
	function fetch_definitions( $options = array() ) {
		$options = wp_parse_args(
			$options,
			array(
				'blocking'    => true,
				'timeout'     => $this->timeout,
			)
		);

		// The base SaaS layer now owns cache-busting policy; this endpoint only sends its required query state.
		return $this->saas_request(
			'saas_update_definitions',
			array(
				'method'      => 'GET',
				'send_state'  => 'query',
				'blocking'    => (bool) $options['blocking'],
				'timeout'     => (float) $options['timeout'],
			)
		);
	}

	/**
	 * Determine whether a definitions version check should reuse cached state.
	 *
	 * When cached checks are enabled, a fresh remote request is skipped when either:
	 * - a newer definitions version is already known locally, or
	 * - a successful definitions freshness action completed within the last 5 minutes.
	 *
	 * @param bool $cached Whether cached availability/throttle rules should apply.
	 * @return bool True when the remote definitions check should be skipped.
	 */
	function should_skip_definitions_check( $cached = true ) {
		// Callers pass cached=false when they need a guaranteed live SaaS check.
		if ( ! $cached ) {
			return false;
		}

		// Once a newer server version is already known, repeated freshness probes add no value.
		if ( $this->definition_updates_available() ) {
			return true;
		}

		$last_checked = absint( $this->get_setting( 'sig_checked_time' ) );
		// Treat sig_checked_time as the last successful freshness check or update when applying the short throttle window.
		if ( $last_checked && ( time() - $last_checked ) < ( 5 * MINUTE_IN_SECONDS ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Check whether updated definitions are available and cache the available server version.
	 *
	 * Call sites:
	 * - Hourly cron (`wpmr_hourly` via `automate_routines()`) and several UI/CLI code paths.
	 *
	 * Behavior:
	 * - Calls `fetch_definitions_version()`.
	 * - When `$cached` is true (default), skips remote checks when updates are already known
	 *   or when a successful check completed in the last 5 minutes.
	 * - Validates the blocking response payload and persists `update-version`.
	 *
	 * Side effects:
	 * - Updates `sig_checked_time` only after a successful blocking check completes.
	 * - Updates `update-version` setting when a valid server version is returned.
	 *
	 * Suggested rename: `check_for_definition_updates()`.
	 *
	 * @param bool $cached When true, reuses cached availability/throttle state before hitting SaaS.
	 * @return true|null Returns true on a successful check; otherwise null.
	 */
	function check_definitions( $cached = true ) {
		// Reuse the shared throttle/update-known gate before contacting SaaS again.
		if ( $this->should_skip_definitions_check( $cached ) ) {
			return true;
		}

		$response = $this->fetch_definitions_version();

		if ( is_wp_error( $response ) ) {
			return;
		}

		$version = isset( $response['response'] ) ? $response['response'] : null;
		if ( empty( $version ) || empty( $version['success'] ) ) {
			return;
		}

		$payload = isset( $response['payload'] ) ? $response['payload'] : null;

		if ( empty( $payload ) || empty( $payload['server_defver'] ) ) {
			return;
		}

		// Persist both the discovered server version and the successful completion time for this check.
		$this->update_setting( 'update-version', $payload['server_defver'] );
		$this->update_setting( 'sig_checked_time', time() );
		return true;
	}

	/**
	 * Fetch the latest definitions version metadata from the SaaS backend.
	 *
	 * This is lighter-weight than fetching full signatures and is used by `check_definitions()`.
	 *
	 * Suggested rename: `fetch_definitions_version_payload()`.
	 *
	 * @param array{blocking?:bool,timeout?:float|int} $options Request options.
	 * @return array|WP_Error SaaS response array on success, or WP_Error on failure.
	 */
	function fetch_definitions_version( $options = array() ) {
		$options = wp_parse_args(
			$options,
			array(
				'blocking'    => true,
				'timeout'     => $this->timeout,
			)
		);

		$def_version = $this->get_definition_version();
		// $this->flog( 'Checking definitions. Current version: ' . ( empty( $def_version ) ? 'none' : $def_version ) );
		if ( ! is_scalar( $def_version ) || null === $def_version ) {
			$def_version = '';
		} else {
			$def_version = (string) $def_version;
		}

		// SaaS only needs the currently installed definitions version to answer this freshness probe.
		return $this->saas_request(
			'saas_check_definitions',
			array(
				'method'      => 'GET',
				'send_state'  => 'query',
				'state_extra' => array( 'defver' => $def_version ),
				'blocking'    => (bool) $options['blocking'],
				'timeout'     => (float) $options['timeout'],
			)
		);
	}

	/**
	 * Clear cached definition-update availability state after local definitions change.
	 *
	 * Successful definition imports or updates should not leave a stale `update-version`
	 * marker behind, otherwise both CLI and admin flows can continue to report an update
	 * that has already been applied.
	 *
	 * Side effects:
	 * - Deletes the `update-version` setting.
	 *
	 * @return void
	 */
	function purge_definition_update_version() {
		$this->delete_setting( 'update-version' );
	}

	/**
	 * Persist fetched definitions and verify the stored version before reporting success.
	 *
	 * This guards against large-option write failures where WordPress would otherwise keep
	 * serving the previous payload while the caller reports a successful update.
	 *
	 * @param array<string,mixed> $definitions_data Fresh definitions payload from SaaS.
	 * @return array<string,mixed>|WP_Error Persisted payload on success, or WP_Error on mismatch.
	 */
	function persist_definitions_payload( $definitions_data ) {
		$expected_version = '';

		if ( is_array( $definitions_data ) && array_key_exists( 'v', $definitions_data ) && is_scalar( $definitions_data['v'] ) ) {
			$expected_version = (string) $definitions_data['v'];
		}

		if ( '' === $expected_version ) {
			return new WP_Error( 'wpmr_invalid_definition_payload', 'Definition payload version missing.' );
		}

		$this->update_setting( 'signatures', $definitions_data );

		$persisted_definitions = $this->get_setting( 'signatures' );
		$persisted_version     = '';

		if ( is_array( $persisted_definitions ) && array_key_exists( 'v', $persisted_definitions ) && is_scalar( $persisted_definitions['v'] ) ) {
			$persisted_version = (string) $persisted_definitions['v'];
		}

		if ( $persisted_version !== $expected_version ) {
			return new WP_Error(
				'wpmr_definition_persist_failed',
				'Definition update could not be verified after saving. Expected version ' . $expected_version . ' but found ' . ( '' === $persisted_version ? 'none' : $persisted_version ) . '.'
			);
		}

		return $persisted_definitions;
	}

	/**
	 * AJAX handler: fetch and persist updated malware definitions.
	 *
	 * Hooked to `wp_ajax_wpmr_update_sigs`.
	 *
	 * Security:
	 * - Verifies nonce `wpmr_update_sigs` via `check_ajax_referer()`.
	 * - Requires the plugin capability stored in `$this->cap`.
	 * - Re-reads the stored signatures payload and verifies the version before success.
	 *
	 * Side effects:
	 * - Updates `signatures`, `sig_time`, and `sig_checked_time` settings after the
	 *   signatures payload is verified as persisted.
	 * - Clears stale cached `update-version` state after a successful update.
	 * - Emits a JSON response via `wp_send_json_success()` / `wp_send_json_error()`.
	 *
	 * Note: `$force` is currently unused.
	 * Suggested rename: `ajax_update_definitions()`.
	 *
	 * @param bool $force Unused.
	 * @return void
	 */
	function update_definitions( $force = false ) {
		check_ajax_referer( 'wpmr_update_sigs', 'wpmr_update_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			return wp_send_json_error( 'Insufficient permissions.' );
		}
		$this->raise_limits_conditionally();
		$response = $this->fetch_definitions();
		if ( is_wp_error( $response ) ) {
			return wp_send_json_error( $response->get_error_message() );
		}

		$definitions = isset( $response['response'] ) ? $response['response'] : null;
		if ( empty( $definitions ) || empty( $definitions['success'] ) ) {
			return wp_send_json_error( 'Unparsable definition-update.' );
		}

		$payload = isset( $response['payload'] ) ? $response['payload'] : null;

		if ( empty( $payload ) || empty( $payload['signatures'] ) ) {
			return wp_send_json_error( 'Empty definition payload.' );
		}

		$definitions_data      = $payload['signatures'];
		$persisted_definitions = $this->persist_definitions_payload( $definitions_data );
		if ( is_wp_error( $persisted_definitions ) ) {
			return wp_send_json_error( $persisted_definitions->get_error_message() );
		}
		$time = gmdate( 'U' );
		$this->update_setting( 'sig_time', $time );
		// A successful payload refresh is also a successful freshness action.
		$this->update_setting( 'sig_checked_time', time() );
		// The cached "latest available" version is stale once local definitions are replaced.
		$this->purge_definition_update_version();
		return wp_send_json_success(
			array(
				'count'    => $this->get_definition_count(),
				'version'  => (string) $persisted_definitions['v'],
				'sig_time' => $this->get_last_updated_ago(),
			)
		);
	}

	/**
	 * Update malware definitions in CLI/automation contexts.
	 *
	 * Call sites:
	 * - Used by WP-CLI commands and by internal automation paths (e.g., stateful scanning).
	 *
	 * Behavior:
	 * - Fetches the full definitions payload via `fetch_definitions()`.
	 * - On success, persists `signatures`, verifies the stored version, then updates
	 *   `sig_time` and `sig_checked_time`.
	 * - Clears stale cached `update-version` state after a successful update.
	 * - When `$echo` is true, prints feedback either via WP-CLI or via `echo`.
	 * - When `$echo` is false, returns a boolean status.
	 *
	 * Caveat:
	 * - In WP-CLI mode with `$echo` true, `WP_CLI::error()` may exit execution.
	 *
	 * Suggested rename: `sync_definitions()` or `update_definitions_non_ajax()`.
	 *
	 * @param bool $echo Whether to emit user-facing output.
	 * @return bool Returns true on successful update when not echoing; false on failure when not echoing.
	 */
	function update_definitions_cli( $echo = false ) {
		$this->raise_limits_conditionally();
		$response = $this->fetch_definitions();
		
		if ( is_wp_error( $response ) ) {
			if ( $echo ) {
				if ( $this->wpmr_iscli() ) {
					WP_CLI::error( $response->get_error_message() );
				} else {
					echo 'Error: ' . esc_html( $response->get_error_message() );
				}
			} else {
				return false;
			}
		}

		$definitions = isset( $response['response'] ) ? $response['response'] : null;
		if ( empty( $definitions ) || empty( $definitions['success'] ) ) {
			if ( $echo ) {
				if ( $this->wpmr_iscli() ) {
					WP_CLI::error( 'Unparsable definition-update.' );
				} else {
					echo 'Unparsable definition-update.';
				}
			} else {
				return false;
			}
		}

		$payload = isset( $response['payload'] ) ? $response['payload'] : null;

		if ( empty( $payload ) || empty( $payload['signatures'] ) ) {
			if ( $echo ) {
				if ( $this->wpmr_iscli() ) {
					WP_CLI::error( 'Empty definition payload.' );
				} else {
					echo 'Empty definition payload.';
				}
			} else {
				return false;
			}
		}

		$definitions_data      = $payload['signatures'];
		$persisted_definitions = $this->persist_definitions_payload( $definitions_data );
		if ( is_wp_error( $persisted_definitions ) ) {
			if ( $echo ) {
				if ( $this->wpmr_iscli() ) {
					WP_CLI::error( $persisted_definitions->get_error_message() );
				} else {
					echo esc_html( $persisted_definitions->get_error_message() );
					return;
				}
			} else {
				return false;
			}
		}
		$time = gmdate( 'U' );
		$this->update_setting( 'sig_time', $time );
		// A successful payload refresh is also a successful freshness action.
		$this->update_setting( 'sig_checked_time', time() );
		// The cached "latest available" version is stale once local definitions are replaced.
		$this->purge_definition_update_version();
		if ( $echo ) {
			if ( $this->wpmr_iscli() ) {
				WP_CLI::success( 'Updated Malcure definitions to version: ' . WP_CLI::colorize( '%Y' . $persisted_definitions['v'] . '. %nCount: %Y' . $this->get_definition_count() . '%n' ) . ' definitions.' );
			} else {
				echo 'Updated Malcure definitions to version <strong>' . esc_html( $persisted_definitions['v'] ) . '</strong>. Count: <strong>' . esc_html( $this->get_definition_count() ) . '</strong> definitions.';
			}
		} else {
			return true;
		}
	}

	/**
	 * Determine whether a newer definitions version is available.
	 *
	 * Compares the local definitions version (`get_definition_version()`) against the cached
	 * server version stored as `update-version` (typically populated by `check_definitions()`).
	 *
	 * Suggested rename: `get_definitions_update_info()` or `get_available_definitions_update()`.
	 *
	 * @return array{new:string,current:string}|null Returns update info when versions differ, otherwise null.
	 */
	function definition_updates_available() {
		$current = $this->get_definition_version();
		$new     = $this->get_setting( 'update-version' );
		// $this->flog( 'Definition versions: current=' . $current . '= new=' . $new . '=' );
		if ( ! empty( $current ) && ! empty( $new ) && $current != $new ) {
			return array(
				'new'     => $new,
				'current' => $current,
			);
		}
	}
}
