<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner\FileIntegrity;

// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Manage file state transitions during a scan.
 */
class FSMEngine {

	/**
	 * Valid state transitions map.
	 *
	 * @var array<string, array<string>>
	 */
	private const VALID_TRANSITIONS = [
		'discovered'         => [ 'hashing', 'scan_skipped', 'error' ],
		'hashing'            => [ 'hash_complete', 'error' ],
		'hash_complete'      => [ 'cloud_hash_check', 'verified', 'safe', 'pending_upload', 'scan_skipped', 'error', 'hash_complete' ],
		'cloud_hash_check'   => [ 'verified', 'safe', 'malicious_detected', 'pending_upload', 'hash_complete', 'scan_skipped', 'error' ],
		// Terminal resolution states (no further transitions; these are not deep-scan results).
		'verified'           => [],
		'safe'               => [],
		'malicious_detected' => [ 'flagged', 'error' ],
		// Upload retry/requeue transitions are intentionally allowed to prevent deadlocks.
		'pending_upload'     => [ 'uploading', 'uploaded', 'hash_complete', 'scan_skipped', 'error', 'pending_upload' ],
		'uploading'          => [ 'uploaded', 'pending_upload', 'hash_complete', 'scan_skipped', 'error' ],
		// Allow direct completion once results are returned (cloud jobs don't explicitly transition to scan_requested/scanning)
		// Also allow recovery back to hash_complete/pending_upload when cloud reports retryable errors.
		'uploaded'           => [ 'scan_requested', 'pending_upload', 'hash_complete', 'scan_skipped', 'completed', 'flagged', 'error' ],
		'scan_requested'     => [ 'scanning', 'pending_upload', 'hash_complete', 'scan_skipped', 'error' ],
		'scanning'           => [ 'completed', 'flagged', 'pending_upload', 'hash_complete', 'scan_skipped', 'error' ],
		'completed'          => [], // Terminal state
		'flagged'            => [], // Terminal state
		'scan_skipped'       => [], // Terminal state (file excluded/skipped)
		'error'              => [ 'pending_upload', 'hashing', 'hash_complete' ], // Allow retry (hash_complete)
	];

	/**
	 * Terminal states (cannot transition further).
	 *
	 * @var array<string>
	 */
	private const TERMINAL_STATES = [ 'completed', 'flagged', 'scan_skipped', 'verified', 'safe' ];

	/**
	 * WordPress database object.
	 *
	 * @var \wpdb
	 */
	private $wpdb;

	/**
	 * File states table name.
	 *
	 * @var string
	 */
	private $table_name;

	public function __construct() {
		global $wpdb;
		$this->wpdb       = $wpdb;
		$this->table_name = $wpdb->prefix . 'sitefort_file_states';
	}

	/**
	 * Transition a file from one state to another atomically.
	 *
	 * @param int    $file_id     File state record ID.
	 * @param string $from_state  Expected current state.
	 * @param string $to_state    Target state.
	 * @param array  $data        Additional data to update.
	 * @return bool|\WP_Error Success or error.
	 */
	public function transition( int $file_id, string $from_state, string $to_state, array $data = [] ) {
		// Validate transition
		if ( ! $this->is_valid_transition( $from_state, $to_state ) ) {
			return new \WP_Error(
				'invalid_transition',
				sprintf( 'Invalid state transition from %s to %s', $from_state, $to_state )
			);
		}

		// Prepare update data
		$update_data = array_merge(
			[
				'state'      => $to_state,
				'updated_at' => time(),
			],
			$data
		);

		// Atomic update with optimistic locking
		$where = [
			'id'    => $file_id,
			'state' => $from_state,
		];

		$result = $this->wpdb->update(
			$this->table_name,
			$update_data,
			$where,
			$this->get_format_array( $update_data ),
			[ '%d', '%s' ]
		);

		if ( false === $result ) {
			// Log database details locally and return a generic error upstream.
			sitefort_log( sprintf(
				'[SiteFort FSM] DB transition failed (file_id=%d, %s→%s): %s',
				$file_id,
				$from_state,
				$to_state,
				$this->wpdb->last_error
			) );
			
			return new \WP_Error(
				'transition_failed',
				'Database operation failed. Check error logs for details.'
			);
		}

		if ( 0 === $result ) {
			return new \WP_Error(
				'transition_conflict',
				sprintf( 'State conflict: file %d is not in expected state %s', $file_id, $from_state )
			);
		}

		// Log transition
		$this->log_transition( $file_id, $from_state, $to_state );

		// Record metric
		$this->record_metric( 'state_transition', $from_state . ' -> ' . $to_state, true );

		return true;
	}



	/**
	 * Bulk transition a batch of files directly from 'discovered' to 'hash_complete'.
	 *
	 * Single UPDATE with CASE WHEN instead of 2N individual queries (discovered->hashing + hashing->hash_complete).
	 * Use only from HashService where single-worker access is guaranteed.
	 *
	 * @param array $hashes    Map of file_id => sha256 hash string.
	 * @param array $sizes     Map of file_id => file size in bytes.
	 * @param array $file_ids  Ordered list of file IDs to update.
	 * @param array $snapshots Map of file_id => component snapshot fields.
	 * @return int Number of rows updated.
	 */
	public function bulk_transition_to_hash_complete( array $hashes, array $sizes, array $file_ids, array $snapshots = [] ): int {
		if ( empty( $file_ids ) ) {
			return 0;
		}

		$now                    = (int) time();
		$id_list                = implode( ',', array_map( 'intval', $file_ids ) );
		$has_snapshots          = ! empty( $snapshots );
		$hash_cases             = '';
		$size_cases             = '';
		$component_slug_cases   = '';
		$component_version_cases = '';
		$component_locale_cases = '';
		$mtime_cases            = '';
		foreach ( $file_ids as $id ) {
			$id          = (int) $id;
			// SHA-256 hashes are hex-only [0-9a-f]{64} - safe for direct interpolation after cast.
			$hash        = preg_replace( '/[^0-9a-f]/i', '', (string) ( $hashes[ $id ] ?? '' ) );
			$size        = (int) ( $sizes[ $id ] ?? 0 );
			$hash_cases .= " WHEN {$id} THEN '{$hash}'";
			$size_cases .= " WHEN {$id} THEN {$size}";

			if ( $has_snapshots ) {
				$snapshot = is_array( $snapshots[ $id ] ?? null ) ? $snapshots[ $id ] : [];
				$component_slug_cases    .= " WHEN {$id} THEN " . $this->sql_string_literal( (string) ( $snapshot['component_slug'] ?? '' ), 191 );
				$component_version_cases .= " WHEN {$id} THEN " . $this->sql_string_literal( (string) ( $snapshot['component_version'] ?? '' ), 100 );
				$component_locale_cases  .= " WHEN {$id} THEN " . $this->sql_string_literal( (string) ( $snapshot['component_locale'] ?? '' ), 20 );
				$mtime                    = max( 0, (int) ( $snapshot['file_mtime_at_hash'] ?? 0 ) );
				$mtime_cases             .= " WHEN {$id} THEN {$mtime}";
			}
		}

		$snapshot_sql = '';
		if ( $has_snapshots ) {
			$snapshot_sql = ",
				component_slug = CASE id {$component_slug_cases} ELSE component_slug END,
				component_version = CASE id {$component_version_cases} ELSE component_version END,
				component_locale = CASE id {$component_locale_cases} ELSE component_locale END,
				file_mtime_at_hash = CASE id {$mtime_cases} ELSE file_mtime_at_hash END";
		}

		$sql = "UPDATE {$this->table_name}
			SET state = 'hash_complete',
				updated_at = {$now},
				file_hash = CASE id {$hash_cases} ELSE file_hash END,
				file_size = CASE id {$size_cases} ELSE file_size END
				{$snapshot_sql}
			WHERE id IN ({$id_list})
			AND state = 'discovered'";

		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.NotPrepared -- Batch statement is assembled from validated integer IDs and sanitized hashes.
		$result = $this->wpdb->query( $sql );
		return is_int( $result ) ? $result : 0;
	}

	/**
	 * Bulk transition a set of files from one state to another (no per-file data).
	 *
	 * Single UPDATE instead of N individual queries. Use for homogeneous terminal transitions
	 * such as hash_complete -> verified or hash_complete -> safe.
	 *
	 * @param array  $file_ids   File IDs to transition.
	 * @param string $from_state Expected current state.
	 * @param string $to_state   Target state.
	 * @return int Number of rows updated.
	 */
	public function bulk_transition( array $file_ids, string $from_state, string $to_state ): int {
		if ( empty( $file_ids ) ) {
			return 0;
		}

		$now          = (int) time();
		$placeholders = implode( ',', array_fill( 0, count( $file_ids ), '%d' ) );
		$params       = array_merge(
			[ $to_state, $now ],
			array_map( 'intval', $file_ids ),
			[ $from_state ]
		);

		$result = $this->query_prepared(
			"UPDATE {$this->table_identifier()}
			SET state = %s, updated_at = %d
			WHERE id IN ({$placeholders}) AND state = %s",
			...$params
		);

		return is_int( $result ) ? $result : 0;
	}

	/**
	 * Bulk transition with homogeneous metadata (same values for every row).
	 *
	 * @param array  $file_ids   File IDs to transition.
	 * @param string $from_state Expected current state.
	 * @param string $to_state   Target state.
	 * @param array  $meta       column => scalar|null for allowlisted columns.
	 * @return int Number of rows updated.
	 */
	public function bulk_transition_with_meta( array $file_ids, string $from_state, string $to_state, array $meta ): int {
		if ( empty( $file_ids ) ) {
			return 0;
		}

		if ( ! $this->is_valid_transition( $from_state, $to_state ) ) {
			return 0;
		}

		$allowed_columns = [ 'hash_status', 'scanned_with_signature', 'scan_result', 'error_code', 'error_message', 'available_at', 'cloud_job_id' ];
		$set_parts       = [ 'state = %s', 'updated_at = %d' ];
		$params          = [ $to_state, time() ];

		foreach ( $meta as $column => $value ) {
			if ( ! in_array( $column, $allowed_columns, true ) ) {
				continue;
			}

			if ( null === $value ) {
				$set_parts[] = "{$column} = NULL";
			} elseif ( is_int( $value ) ) {
				$set_parts[] = "{$column} = %d";
				$params[]    = $value;
			} else {
				$set_parts[] = "{$column} = %s";
				$params[]    = (string) $value;
			}
		}

		$placeholders = implode( ',', array_fill( 0, count( $file_ids ), '%d' ) );
		$params       = array_merge( $params, array_map( 'intval', $file_ids ), [ $from_state ] );

		$result = $this->query_prepared(
			"UPDATE {$this->table_identifier()}
			SET " . implode( ', ', $set_parts ) . "
			WHERE id IN ({$placeholders}) AND state = %s",
			...$params
		);

		return is_int( $result ) ? $result : 0;
	}

	/**
	 * Check whether any files exist in any of the given states.
	 *
	 * Single query with IN clause instead of N separate get_files_by_state() calls.
	 * Used for cheap existence checks (hashing complete, hash production idle, etc.).
	 *
	 * @param string   $scan_id Scan UUID.
	 * @param string[] $states  States to check.
	 * @return bool True if at least one file exists in any of those states.
	 */
	public function has_files_in_states( string $scan_id, array $states ): bool {
		if ( empty( $states ) ) {
			return false;
		}
		$placeholders = implode( ',', array_fill( 0, count( $states ), '%s' ) );
		$row = $this->get_var_prepared(
			"SELECT 1 FROM {$this->table_identifier()}
			WHERE scan_id = %s AND state IN ({$placeholders})
			LIMIT 1",
			$scan_id,
			...$states
		);
		return $row !== null;
	}

	/**
	 * Get files in a specific state for processing.
	 *
	 * @param string $scan_id Scan UUID.
	 * @param string $state   State to filter by.
	 * @param int    $limit   Maximum number of files to return.
	 * @return array File records.
	 */
	public function get_files_by_state( string $scan_id, string $state, int $limit = 50 ): array {
		$results = $this->get_results_prepared(
			"SELECT * FROM {$this->table_identifier()}
			WHERE scan_id = %s
			AND state = %s
			ORDER BY created_at ASC
			LIMIT %d",
			$scan_id,
			$state,
			$limit
		);

		return $results ?: [];
	}

	/**
	 * Get ready cloud hash-check rows for hash_complete or cloud_hash_check.
	 */
	public function get_cloud_check_ready_files_by_state( string $scan_id, string $state, int $limit = 50 ): array {
		$results = $this->get_results_prepared(
			"SELECT * FROM {$this->table_identifier()}
			WHERE scan_id = %s
			AND state = %s
			AND (
				error_code IS NULL
				OR error_code <> 'repo_manifest_refresh_pending'
				OR COALESCE(NULLIF(available_at, 0), updated_at) <= %d
			)
			ORDER BY created_at ASC
			LIMIT %d",
			$scan_id,
			$state,
			time(),
			$limit
		);

		return $results ?: [];
	}

	/**
	 * Earliest delayed manifest-refresh hash recheck for this scan.
	 */
	public function get_next_deferred_manifest_recheck_at( string $scan_id, int $now ): int {
		$next = $this->get_var_prepared(
			"SELECT MIN(COALESCE(NULLIF(available_at, 0), updated_at))
			FROM {$this->table_identifier()}
			WHERE scan_id = %s
			AND state IN ('hash_complete', 'cloud_hash_check')
			AND error_code = 'repo_manifest_refresh_pending'
			AND COALESCE(NULLIF(available_at, 0), updated_at) > %d",
			$scan_id,
			$now
		);

		return $next ? (int) $next : 0;
	}

	/**
	 * Get scan progress statistics.
	 *
	 * @param string $scan_id Scan UUID.
	 * @return array Progress data.
	 */
	public function get_scan_progress( string $scan_id ): array {
		$stats = $this->get_results_prepared(
			"SELECT
				state,
				COUNT(*) as count
			FROM {$this->table_identifier()}
			WHERE scan_id = %s
			GROUP BY state",
			$scan_id
		);

		$progress = [
			'total'              => 0,
			'completed'          => 0,
			'flagged'            => 0,
			'error'              => 0,
			'verified'           => 0,
			'safe'               => 0,
			'scan_skipped'       => 0,
			'malicious_detected' => 0,
			'in_progress'        => 0,
			'by_state'           => [],
		];

		foreach ( $stats as $stat ) {
			$state                      = $stat['state'];
			$count                      = (int) $stat['count'];
			$progress['by_state'][ $state ] = $count;
			$progress['total']         += $count;

			if ( 'completed' === $state ) {
				$progress['completed'] = $count;
			} elseif ( 'flagged' === $state ) {
				$progress['flagged'] = $count;
			} elseif ( 'error' === $state ) {
				$progress['error'] = $count;
			} elseif ( 'verified' === $state ) {
				$progress['verified'] = $count;
			} elseif ( 'safe' === $state ) {
				$progress['safe'] = $count;
			} elseif ( 'scan_skipped' === $state ) {
				$progress['scan_skipped'] = $count;
			} elseif ( 'malicious_detected' === $state ) {
				$progress['malicious_detected'] = $count;
			} elseif ( ! in_array( $state, self::TERMINAL_STATES, true ) ) {
				$progress['in_progress'] += $count;
                
                // Detailed breakdown
                if ( in_array( $state, ['discovered', 'hashing', 'hash_complete'] ) ) {
                    $progress['local_processing'] = ($progress['local_processing'] ?? 0) + $count;
                } elseif ( in_array( $state, ['cloud_hash_check', 'pending_upload'] ) ) {
                     $progress['cloud_checking'] = ($progress['cloud_checking'] ?? 0) + $count;
                } elseif ( in_array( $state, ['uploading', 'uploaded', 'scan_requested', 'scanning'] ) ) {
                     $progress['cloud_analysis'] = ($progress['cloud_analysis'] ?? 0) + $count;
                }
			}
		}

		$terminal_for_percent = ( $progress['completed'] ?? 0 )
			+ ( $progress['flagged'] ?? 0 )
			+ ( $progress['error'] ?? 0 )
			+ ( $progress['verified'] ?? 0 )
			+ ( $progress['safe'] ?? 0 )
			+ ( $progress['scan_skipped'] ?? 0 );

		$progress['percent_complete'] = $progress['total'] > 0
			? round( $terminal_for_percent / $progress['total'] * 100, 2 )
			: 0;

		return $progress;
	}

	/**
	 * Validate that a state transition is allowed.
	 *
	 * @param string $from_state Current state.
	 * @param string $to_state   Target state.
	 * @return bool Valid transition.
	 */
	private function is_valid_transition( string $from_state, string $to_state ): bool {
		if ( ! isset( self::VALID_TRANSITIONS[ $from_state ] ) ) {
			return false;
		}

		return in_array( $to_state, self::VALID_TRANSITIONS[ $from_state ], true );
	}

	/**
	 * Log a state transition.
	 *
	 * @param int    $file_id    File ID.
	 * @param string $from_state From state.
	 * @param string $to_state   To state.
	 */
	private function log_transition( int $file_id, string $from_state, string $to_state ): void {
		do_action( 'sitefort_fsm_transition', $file_id, $from_state, $to_state );
	}

	/**
	 * Record a performance metric.
	 *
	 * @param string $type    Metric type.
	 * @param string $name    Operation name.
	 * @param bool   $success Success flag.
	 */
	private function record_metric( string $type, string $name, bool $success ): void {
		do_action( 'sitefort_fsm_metric', $type, $name, $success );
	}

	/**
	 * Get format array for wpdb update/insert based on data types.
	 *
	 * @param array $data Data array.
	 * @return array Format array.
	 */
	private function get_format_array( array $data ): array {
		$formats = [];

		foreach ( $data as $value ) {
			if ( is_int( $value ) ) {
				$formats[] = '%d';
			} elseif ( is_float( $value ) ) {
				$formats[] = '%f';
			} else {
				$formats[] = '%s';
			}
		}

		return $formats;
	}

	private function sql_string_literal( string $value, int $max_length ): string {
		$value = sanitize_text_field( $value );
		if ( strlen( $value ) > $max_length ) {
			$value = substr( $value, 0, $max_length );
		}

		return "'" . esc_sql( $value ) . "'";
	}

	/**
	 * Escaped table identifier.
	 */
	private function table_identifier(): string {
		if ( $this->table_name === '' || strlen( $this->table_name ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $this->table_name ) ) {
			throw new \InvalidArgumentException( 'Invalid table identifier.' );
		}

		return '`' . $this->table_name . '`';
	}

	/**
	 * Read rows with prepared SQL.
	 *
	 * @param string $sql SQL with placeholders.
	 * @param mixed  ...$params Statement parameters.
	 * @return array<int,array<string,mixed>>
	 */
	private function get_results_prepared( string $sql, ...$params ): array {
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Literal SQL is prepared centrally by this repository helper before execution.
		$prepared = $this->wpdb->prepare( $sql, ...$params );
		if ( ! is_string( $prepared ) || '' === $prepared ) {
			return [];
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared scanner query.
		$results = $this->wpdb->get_results( $prepared, ARRAY_A );
		return is_array( $results ) ? $results : [];
	}

	/**
	 * Read one scalar with prepared SQL.
	 *
	 * @param string $sql SQL with placeholders.
	 * @param mixed  ...$params Statement parameters.
	 * @return mixed
	 */
	private function get_var_prepared( string $sql, ...$params ) {
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Bound with wpdb::prepare().
		$prepared = $this->wpdb->prepare( $sql, ...$params );
		if ( ! is_string( $prepared ) || '' === $prepared ) {
			return null;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared scanner query.
		return $this->wpdb->get_var( $prepared );
	}

	/**
	 * Execute prepared SQL.
	 *
	 * @param string $sql SQL with placeholders.
	 * @param mixed  ...$params Statement parameters.
	 * @return int|false
	 */
	private function query_prepared( string $sql, ...$params ) {
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Literal SQL is prepared centrally by this helper before execution.
		$prepared = $this->wpdb->prepare( $sql, ...$params );
		if ( ! is_string( $prepared ) || '' === $prepared ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Executes a prepared statement against the plugin-owned scanner table.
		return $this->wpdb->query( $prepared );
	}

}
