<?php
/**
 * Source-file ownership scanner.
 *
 * Attributes database items (options, tables, cron hooks, meta keys) to the
 * plugin/theme/must-use plugin that references them, by scanning installed
 * source code for exact identifier occurrences. Runs only on demand, in
 * resumable time-boxed steps, and writes results to {@see Ownership_Store}.
 *
 * @package DatabaseCleanup
 */

namespace DatabaseCleanup\Includes;

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

class Source_Scanner {

	public const JOB_PREFIX  = 'wpps_dc_owner_job_';
	public const ACTIVE_KEY  = 'wpps_dc_owner_active_';
	public const DATA_PREFIX = 'wpps_dc_owner_data_';
	public const HITS_PREFIX = 'wpps_dc_owner_hits_';
	public const JOB_TTL     = 1800;

	private const MAX_FILE_BYTES = 2097152;
	private const MAX_FILES      = 80000;
	private const MAX_NEEDLES    = 20000;
	private const STEP_SECONDS   = 2.0;
	private const STEP_MAX_FILES = 800;
	private const SKIP_DIRS      = [ 'node_modules', '.git', '.svn' ];

	/**
	 * Register store-invalidation hooks (called once when the module boots).
	 */
	public static function register_hooks(): void {
		$mark_stale = static function (): void {
			Ownership_Store::mark_stale();
		};

		add_action( 'activated_plugin', $mark_stale );
		add_action( 'deactivated_plugin', $mark_stale );
		add_action( 'switch_theme', $mark_stale );
		add_action( 'upgrader_process_complete', $mark_stale );
	}

	/**
	 * Start a scan job. Returns job metadata or a cached/no-op marker.
	 *
	 * @return array<string, mixed>
	 */
	public function create_job( int $user_id ): array {
		$owners = self::build_owners();
		$files  = self::collect_files( $owners );
		$needles = self::prepare_needles();

		if ( $files === [] || $needles === [] ) {
			Ownership_Store::mark_scanned();

			return [ 'done' => true, 'empty' => true ];
		}

		$job_id = wp_generate_uuid4();

		update_option(
			self::DATA_PREFIX . $job_id,
			[
				'owners'  => $owners,
				'files'   => $files,
				'needles' => $needles,
			],
			false
		);
		update_option( self::HITS_PREFIX . $job_id, [], false );

		$job = [
			'job_id'       => $job_id,
			'user_id'      => $user_id,
			'cursor'       => 0,
			'total_files'  => count( $files ),
			'needle_count' => count( $needles ),
			'started_at'   => time(),
		];

		set_transient( self::JOB_PREFIX . $job_id, $job, self::JOB_TTL );
		set_transient( self::ACTIVE_KEY . $user_id, $job_id, self::JOB_TTL );

		return [
			'done'        => false,
			'job_id'      => $job_id,
			'total_files' => count( $files ),
			'total_items' => count( $needles ),
		];
	}

	/**
	 * Advance a scan job by one time-boxed batch of files.
	 *
	 * @return array<string, mixed>
	 */
	public function run_step( string $job_id, int $user_id ): array {
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( 60 );
		}

		if ( ! self::is_valid_job_id( $job_id ) ) {
			return [ 'done' => true, 'error' => __( 'Owner scan job not found or expired.', 'wp-powersuite' ) ];
		}

		$job = get_transient( self::JOB_PREFIX . $job_id );
		if ( ! is_array( $job ) ) {
			return [ 'done' => true, 'error' => __( 'Owner scan job not found or expired.', 'wp-powersuite' ) ];
		}

		if ( $user_id > 0 && (int) ( $job['user_id'] ?? 0 ) !== $user_id ) {
			return [ 'done' => true, 'error' => __( 'You are not allowed to run this scan job.', 'wp-powersuite' ) ];
		}

		$data = get_option( self::DATA_PREFIX . $job_id, [] );
		if ( ! is_array( $data ) || empty( $data['files'] ) ) {
			$this->cleanup_job( $job_id, $user_id );

			return [ 'done' => true, 'error' => __( 'Owner scan data expired. Please rescan.', 'wp-powersuite' ) ];
		}

		$files   = (array) $data['files'];
		$needles = (array) $data['needles'];
		$owners  = (array) $data['owners'];
		$hits    = get_option( self::HITS_PREFIX . $job_id, [] );
		$hits    = is_array( $hits ) ? $hits : [];

		$cursor      = max( 0, (int) ( $job['cursor'] ?? 0 ) );
		$total_files = count( $files );
		$deadline    = microtime( true ) + self::STEP_SECONDS;
		$processed   = 0;

		while ( $cursor < $total_files && $processed < self::STEP_MAX_FILES && microtime( true ) < $deadline ) {
			$entry = $files[ $cursor ];
			++$cursor;
			++$processed;

			$owner_index = (int) ( $entry[0] ?? -1 );
			$path        = (string) ( $entry[1] ?? '' );
			if ( $path === '' || ! is_string( $path ) ) {
				continue;
			}

			$this->scan_file( $path, $owner_index, $needles, $hits );
		}

		$job['cursor'] = $cursor;
		update_option( self::HITS_PREFIX . $job_id, $hits, false );

		if ( $cursor >= $total_files ) {
			$summary = $this->finalize( $needles, $owners, $hits );
			$this->cleanup_job( $job_id, $user_id );

			return [
				'done'     => true,
				'progress' => 100,
				'summary'  => $summary,
			];
		}

		set_transient( self::JOB_PREFIX . $job_id, $job, self::JOB_TTL );
		set_transient( self::ACTIVE_KEY . $user_id, $job_id, self::JOB_TTL );

		return [
			'done'      => false,
			'progress'  => (int) round( ( $cursor / max( 1, $total_files ) ) * 100 ),
			'scanned'   => $cursor,
			'total'     => $total_files,
		];
	}

	public function abort( int $user_id ): void {
		$active = get_transient( self::ACTIVE_KEY . $user_id );
		if ( is_string( $active ) && $active !== '' ) {
			$this->cleanup_job( $active, $user_id );
		}
	}

	/**
	 * Remove any leftover scan job payload options (e.g. from an abandoned scan).
	 */
	public static function cleanup_all(): void {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query(
			"DELETE FROM {$wpdb->options}
			WHERE option_name LIKE '" . self::DATA_PREFIX . "%'
			OR option_name LIKE '" . self::HITS_PREFIX . "%'"
		);
	}

	/**
	 * Scan one file: extract identifier-like tokens and record a hit for the
	 * owning plugin/theme against every token in the needle set.
	 *
	 * @param array<string, mixed>            $needles
	 * @param array<string, array<int, int>>  $hits
	 */
	private function scan_file( string $path, int $owner_index, array $needles, array &$hits ): void {
		if ( $owner_index < 0 ) {
			return;
		}

		$size = @filesize( $path );
		if ( $size === false || $size === 0 || $size > self::MAX_FILE_BYTES ) {
			return;
		}

		$content = @file_get_contents( $path );
		if ( ! is_string( $content ) || $content === '' ) {
			return;
		}

		if ( ! preg_match_all( '/[A-Za-z_][A-Za-z0-9_\-]{3,}/', $content, $matches ) ) {
			return;
		}

		foreach ( array_unique( $matches[0] ) as $token ) {
			if ( isset( $needles[ $token ] ) ) {
				$hits[ $token ][ $owner_index ] = ( $hits[ $token ][ $owner_index ] ?? 0 ) + 1;
			}
		}
	}

	/**
	 * Turn accumulated hits into attribution entries and persist them.
	 *
	 * @param array<string, array<string, bool>> $needles token => group => true
	 * @param array<int, array<string, mixed>>   $owners
	 * @param array<string, array<int, int>>     $hits
	 * @return array<string, int>
	 */
	private function finalize( array $needles, array $owners, array $hits ): array {
		$entries = [ 'option' => [], 'table' => [], 'cron' => [], 'meta' => [] ];
		$now     = time();
		$owned   = 0;

		foreach ( $needles as $token => $groups ) {
			$token_hits = $hits[ (string) $token ] ?? [];

			if ( $token_hits === [] ) {
				$result = [ 't' => 'unknown', 'c' => 0, 'by' => 'scan', 'at' => $now ];
			} else {
				arsort( $token_hits );
				$total      = array_sum( $token_hits );
				$candidates = [];

				foreach ( $token_hits as $owner_index => $count ) {
					$owner = $owners[ $owner_index ] ?? null;
					if ( ! is_array( $owner ) ) {
						continue;
					}
					$candidates[] = [
						't'  => (string) $owner['type'],
						's'  => (string) $owner['slug'],
						'n'  => (string) $owner['name'],
						'st' => (string) $owner['status'],
						'h'  => (int) $count,
					];
				}

				if ( $candidates === [] ) {
					$result = [ 't' => 'unknown', 'c' => 0, 'by' => 'scan', 'at' => $now ];
				} else {
					$primary    = $candidates[0];
					$confidence = count( $candidates ) === 1
						? 100
						: max( 55, (int) round( ( $primary['h'] / max( 1, $total ) ) * 100 ) );

					$result = [
						't'    => $primary['t'],
						's'    => $primary['s'],
						'n'    => $primary['n'],
						'st'   => $primary['st'],
						'c'    => $confidence,
						'by'   => 'scan',
						'cand' => array_slice( $candidates, 0, 5 ),
						'at'   => $now,
					];
					++$owned;
				}
			}

			foreach ( (array) $groups as $group => $flag ) {
				if ( isset( $entries[ $group ] ) ) {
					$entries[ $group ][ Ownership_Store::normalize_key( $group, (string) $token ) ] = $result;
				}
			}
		}

		foreach ( $entries as $group => $group_entries ) {
			Ownership_Store::replace( $group, $group_entries );
		}

		Ownership_Store::mark_scanned();

		return [
			'items'     => count( $needles ),
			'attributed'=> $owned,
		];
	}

	private function cleanup_job( string $job_id, int $user_id ): void {
		delete_transient( self::JOB_PREFIX . $job_id );
		delete_option( self::DATA_PREFIX . $job_id );
		delete_option( self::HITS_PREFIX . $job_id );

		$active = get_transient( self::ACTIVE_KEY . $user_id );
		if ( is_string( $active ) && $active === $job_id ) {
			delete_transient( self::ACTIVE_KEY . $user_id );
		}
	}

	public static function is_valid_job_id( string $job_id ): bool {
		return (bool) preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $job_id );
	}

	/**
	 * Build the list of scannable owners (plugins, must-use plugins, themes)
	 * with their filesystem root, slug, display name and live status.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private static function build_owners(): array {
		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$owners = [];
		$active = (array) get_option( 'active_plugins', [] );
		if ( is_multisite() ) {
			$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', [] ) ) );
		}

		foreach ( get_plugins() as $file => $data ) {
			$dir  = dirname( $file );
			$slug = $dir === '.' ? basename( $file, '.php' ) : $dir;
			$root = $dir === '.' ? WP_PLUGIN_DIR . '/' . $file : WP_PLUGIN_DIR . '/' . $dir;

			$owners[ 'plugin:' . $slug ] = [
				'type'   => 'plugin',
				'slug'   => $slug,
				'name'   => (string) ( $data['Name'] ?? $slug ),
				'status' => in_array( $file, $active, true ) ? 'active' : 'inactive',
				'root'   => $root,
			];
		}

		foreach ( get_mu_plugins() as $file => $data ) {
			$slug = basename( $file, '.php' );
			$key  = 'plugin:' . $slug;
			if ( $slug === '' || isset( $owners[ $key ] ) ) {
				continue;
			}
			$owners[ $key ] = [
				'type'   => 'plugin',
				'slug'   => $slug,
				'name'   => (string) ( $data['Name'] ?? $slug ),
				'status' => 'active',
				'root'   => WPMU_PLUGIN_DIR . '/' . $file,
			];
		}

		if ( defined( 'WPMU_PLUGIN_DIR' ) && is_dir( WPMU_PLUGIN_DIR ) ) {
			foreach ( (array) glob( WPMU_PLUGIN_DIR . '/*', GLOB_ONLYDIR ) as $dir ) {
				$slug = basename( (string) $dir );
				$key  = 'plugin:' . $slug;
				if ( isset( $owners[ $key ] ) ) {
					continue;
				}
				$owners[ $key ] = [
					'type'   => 'plugin',
					'slug'   => $slug,
					'name'   => $slug,
					'status' => 'active',
					'root'   => (string) $dir,
				];
			}
		}

		$active_themes = array_unique( array_filter( [ (string) get_stylesheet(), (string) get_template() ] ) );
		foreach ( wp_get_themes() as $slug => $theme ) {
			$owners[ 'theme:' . $slug ] = [
				'type'   => 'theme',
				'slug'   => (string) $slug,
				'name'   => (string) $theme->get( 'Name' ),
				'status' => in_array( (string) $slug, $active_themes, true ) ? 'active' : 'inactive',
				'root'   => (string) $theme->get_stylesheet_directory(),
			];
		}

		return array_values( $owners );
	}

	/**
	 * Collect PHP files for every owner as [ owner_index, path ] pairs.
	 *
	 * @param array<int, array<string, mixed>> $owners
	 * @return array<int, array{0:int,1:string}>
	 */
	private static function collect_files( array $owners ): array {
		$files = [];

		foreach ( $owners as $index => $owner ) {
			$root = (string) ( $owner['root'] ?? '' );
			if ( $root === '' ) {
				continue;
			}

			if ( is_file( $root ) ) {
				$files[] = [ $index, $root ];
				continue;
			}

			if ( ! is_dir( $root ) ) {
				continue;
			}

			try {
				$iterator = new \RecursiveIteratorIterator(
					new \RecursiveCallbackFilterIterator(
						new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ),
						static function ( \SplFileInfo $current ): bool {
							if ( $current->isDir() ) {
								return ! in_array( $current->getFilename(), self::SKIP_DIRS, true );
							}

							return strtolower( $current->getExtension() ) === 'php';
						}
					),
					\RecursiveIteratorIterator::LEAVES_ONLY
				);

				foreach ( $iterator as $file ) {
					if ( $file instanceof \SplFileInfo && $file->isFile() ) {
						$files[] = [ $index, $file->getPathname() ];
						if ( count( $files ) >= self::MAX_FILES ) {
							return $files;
						}
					}
				}
			} catch ( \Throwable $e ) {
				continue;
			}
		}

		return $files;
	}

	/**
	 * Gather the identifiers that need attribution, grouped by entity family.
	 * Core/protected items are excluded (they resolve to WordPress directly).
	 *
	 * @return array<string, array<string, bool>> token => [ group => true ]
	 */
	private static function prepare_needles(): array {
		global $wpdb;

		$needles = [];

		$add = static function ( string $token, string $group ) use ( &$needles ): void {
			$token = trim( $token );
			if ( strlen( $token ) < 4 || count( $needles ) >= self::MAX_NEEDLES ) {
				return;
			}
			$key = $group === 'table' ? strtolower( $token ) : $token;
			$needles[ $key ]['groups'][ $group ] = true;
		};

		// Options (excluding transients + protected/core).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$options = $wpdb->get_col(
			"SELECT option_name FROM {$wpdb->options}
			WHERE option_name NOT LIKE '\_transient\_%'
			AND option_name NOT LIKE '\_site\_transient\_%'"
		);
		foreach ( (array) $options as $name ) {
			$name = (string) $name;
			if ( ! Protected_Items::is_protected_option( $name ) ) {
				$add( $name, 'option' );
			}
		}

		// Transient base names.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$transients = $wpdb->get_col(
			"SELECT option_name FROM {$wpdb->options}
			WHERE option_name LIKE '\_transient\_%'
			AND option_name NOT LIKE '\_transient\_timeout\_%'"
		);
		foreach ( (array) $transients as $name ) {
			$base = substr( (string) $name, strlen( '_transient_' ) );
			if ( $base !== '' && ! Protected_Items::is_protected_transient( $base ) ) {
				$add( $base, 'option' );
			}
		}

		// Custom table suffixes.
		$db_name = defined( 'DB_NAME' ) ? DB_NAME : '';
		if ( $db_name !== '' ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$tables = $wpdb->get_col(
				$wpdb->prepare(
					'SELECT TABLE_NAME FROM information_schema.TABLES WHERE table_schema = %s AND TABLE_NAME LIKE %s',
					$db_name,
					$wpdb->esc_like( $wpdb->prefix ) . '%'
				)
			);
			$prefix_len = strlen( (string) $wpdb->prefix );
			foreach ( (array) $tables as $table ) {
				$table = (string) $table;
				if ( Protected_Items::is_core_table( $table ) ) {
					continue;
				}
				$suffix = substr( $table, $prefix_len );
				if ( $suffix !== '' ) {
					$add( $suffix, 'table' );
				}
			}
		}

		// Cron hooks.
		$cron = function_exists( '_get_cron_array' ) ? _get_cron_array() : [];
		if ( is_array( $cron ) ) {
			foreach ( $cron as $events ) {
				if ( ! is_array( $events ) ) {
					continue;
				}
				foreach ( array_keys( $events ) as $hook ) {
					$hook = (string) $hook;
					if ( ! str_starts_with( $hook, 'wp_' ) ) {
						$add( $hook, 'cron' );
					}
				}
			}
		}

		// Distinct meta keys across the four meta tables.
		$meta_tables = [ $wpdb->postmeta, $wpdb->usermeta, $wpdb->commentmeta, $wpdb->termmeta ];
		foreach ( $meta_tables as $meta_table ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$keys = $wpdb->get_col( "SELECT DISTINCT meta_key FROM {$meta_table} LIMIT 5000" );
			foreach ( (array) $keys as $meta_key ) {
				$meta_key = (string) $meta_key;
				if ( $meta_key !== '' && ! str_starts_with( $meta_key, '_edit_' ) ) {
					$add( $meta_key, 'meta' );
				}
			}
		}

		$flat = [];
		foreach ( $needles as $token => $info ) {
			$flat[ (string) $token ] = $info['groups'];
		}

		return $flat;
	}
}
