<?php
/**
 * Item ownership detection (local SmartScan-style heuristics + manual overrides).
 *
 * @package DatabaseCleanup
 */

namespace DatabaseCleanup\Includes;

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

class Item_Ownership {

	public const OVERRIDE_OPTION = 'wpps_dc_ownership_overrides';

	/** @var array<string, array<string, mixed>>|null */
	private static ?array $plugin_index = null;

	/** @var array<string, string>|null alias => plugin slug */
	private static ?array $plugin_alias_index = null;

	/** @var array<string, array<string, mixed>>|null */
	private static ?array $theme_index = null;

	/** @var array<string, string>|null prefix => plugin slug */
	private static ?array $dynamic_table_prefixes = null;

	/**
	 * @param array<string, mixed> $context
	 * @return array<string, mixed>
	 */
	public static function resolve( string $item_type, string $identifier, array $context = [] ): array {
		$key = self::override_key( $item_type, $identifier, $context );
		$manual = self::get_overrides()[ $key ] ?? null;

		if ( is_array( $manual ) ) {
			return self::normalize( $manual, true );
		}

		$detected = match ( $item_type ) {
			'table'       => self::detect_table( $identifier ),
			'option', 'transient' => self::detect_option( $identifier ),
			'post_meta', 'user_meta', 'comment_meta', 'term_meta' => self::detect_meta( $identifier, (string) ( $context['meta_key'] ?? $identifier ) ),
			'cron'        => self::detect_cron( $identifier, $context ),
			'post_type'   => self::detect_wordpress( 100 ),
			default       => self::detect_unknown(),
		};

		// A completed source scan is authoritative for non-core items: it beats the
		// naming heuristics because it proves which installed plugin/theme actually
		// references the identifier. Core/protected results (WordPress) are never
		// overridden, so a stray scan hit can never expose core data.
		$scanned = self::scan_result( $item_type, $identifier, $context );
		if ( is_array( $scanned ) && ( $scanned['t'] ?? '' ) !== 'unknown' && ( $detected['type'] ?? '' ) !== 'wordpress' ) {
			$detected = self::scan_to_ownership( $scanned );
		} elseif ( is_array( $scanned ) ) {
			$detected['scanned'] = true;
		}

		$detected = self::finalize_addon_ownership( $detected );

		if ( ! empty( $context['orphaned'] ) ) {
			$detected = self::mark_as_orphan( $detected );
		}

		return self::normalize( $detected, false );
	}

	/**
	 * Fetch a stored source-scan attribution for an item, if the store has data.
	 *
	 * @param array<string, mixed> $context
	 * @return array<string, mixed>|null
	 */
	private static function scan_result( string $item_type, string $identifier, array $context ): ?array {
		if ( ! Ownership_Store::has_data() ) {
			return null;
		}

		return Ownership_Store::get(
			Ownership_Store::group_for( $item_type ),
			self::store_lookup_key( $item_type, $identifier, $context )
		);
	}

	/**
	 * Build the store key for an item, matching how the scanner recorded it.
	 *
	 * @param array<string, mixed> $context
	 */
	private static function store_lookup_key( string $item_type, string $identifier, array $context ): string {
		switch ( $item_type ) {
			case 'option':
			case 'transient':
				return self::strip_transient_prefix( $identifier );
			case 'table':
				global $wpdb;
				$prefix = (string) $wpdb->prefix;
				$suffix = str_starts_with( $identifier, $prefix ) ? substr( $identifier, strlen( $prefix ) ) : $identifier;
				return strtolower( $suffix );
			case 'cron':
				return (string) ( $context['hook'] ?? $identifier );
			case 'post_meta':
			case 'user_meta':
			case 'comment_meta':
			case 'term_meta':
				return (string) ( $context['meta_key'] ?? $identifier );
			default:
				return $identifier;
		}
	}

	private static function strip_transient_prefix( string $name ): string {
		foreach ( [ '_site_transient_timeout_', '_transient_timeout_', '_site_transient_', '_transient_' ] as $prefix ) {
			if ( str_starts_with( $name, $prefix ) ) {
				return substr( $name, strlen( $prefix ) );
			}
		}

		return $name;
	}

	/**
	 * Convert a stored scan record into a detection array, re-deriving live
	 * install status so an owner uninstalled after scanning becomes an orphan.
	 *
	 * @param array<string, mixed> $scan
	 * @return array<string, mixed>
	 */
	private static function scan_to_ownership( array $scan ): array {
		$type = (string) ( $scan['t'] ?? 'unknown' );
		$slug = (string) ( $scan['s'] ?? '' );

		$owners = [];
		foreach ( (array) ( $scan['cand'] ?? [] ) as $candidate ) {
			if ( ! is_array( $candidate ) ) {
				continue;
			}
			$c_type = (string) ( $candidate['t'] ?? '' );
			$c_slug = (string) ( $candidate['s'] ?? '' );
			$owners[] = [
				'type'       => $c_type,
				'label'      => (string) ( $candidate['n'] ?? $c_slug ),
				'slug'       => $c_slug,
				'confidence' => max( 0, min( 100, (int) ( $scan['c'] ?? 0 ) ) ),
				'status'     => self::status_for_slug( $c_type, $c_slug ),
			];
		}

		return [
			'type'       => $type,
			'label'      => (string) ( $scan['n'] ?? $slug ),
			'slug'       => $slug,
			'confidence' => max( 0, min( 100, (int) ( $scan['c'] ?? 0 ) ) ),
			'status'     => self::status_for_slug( $type, $slug ),
			'by'         => 'scan',
			'scanned'    => true,
			'owners'     => $owners !== [] ? $owners : null,
		];
	}

	/**
	 * Whether a scheduled cron hook has no registered callback (from a removed plugin).
	 *
	 * Missing callbacks alone are not enough — many plugins register cron handlers
	 * only on certain requests. We only treat a hook as orphan when it also has no
	 * active plugin/theme ownership match.
	 */
	public static function is_cron_hook_orphan( string $hook ): bool {
		$hook = trim( $hook );
		if ( $hook === '' ) {
			return false;
		}

		if ( has_action( $hook ) || has_filter( $hook ) ) {
			return false;
		}

		if ( self::is_core_cron_hook( $hook ) ) {
			return false;
		}

		if ( Protected_Items::is_protected_cron_hook( $hook ) ) {
			return false;
		}

		$ownership = self::detect_cron( $hook, [ 'hook' => $hook ] );
		$type      = (string) ( $ownership['type'] ?? '' );
		$status    = (string) ( $ownership['status'] ?? '' );

		// Owned by an installed plugin/theme (active or inactive) — keep it.
		if ( in_array( $type, [ 'plugin', 'theme' ], true ) && $status !== 'not_installed' ) {
			return false;
		}

		if ( $type === 'wordpress' ) {
			return false;
		}

		// Only orphan when the owner is gone, or completely unknown with no callback.
		return $status === 'not_installed' || $type === 'unknown';
	}

	/**
	 * Describe the callbacks registered for a cron hook (name + file:line), so the
	 * detail view can show what code actually handles a scheduled event.
	 *
	 * @return list<array{callback:string,location:string}>
	 */
	public static function cron_hook_callbacks( string $hook ): array {
		global $wp_filter;

		$hook = trim( $hook );
		if ( $hook === '' || ! isset( $wp_filter[ $hook ] ) ) {
			return [];
		}

		$registered = $wp_filter[ $hook ];
		$callbacks  = $registered instanceof \WP_Hook ? $registered->callbacks : (array) $registered;
		$out        = [];

		foreach ( $callbacks as $group ) {
			foreach ( (array) $group as $entry ) {
				$info = self::describe_callable( $entry['function'] ?? null );
				if ( $info !== null ) {
					$out[] = $info;
				}
				if ( count( $out ) >= 20 ) {
					return $out;
				}
			}
		}

		return $out;
	}

	/**
	 * @param mixed $callable
	 * @return array{callback:string,location:string}|null
	 */
	private static function describe_callable( $callable ): ?array {
		try {
			if ( is_string( $callable ) && $callable !== '' ) {
				$reflection = str_contains( $callable, '::' )
					? new \ReflectionMethod( ...explode( '::', $callable, 2 ) )
					: new \ReflectionFunction( $callable );
				$name = $callable;
			} elseif ( is_array( $callable ) && isset( $callable[0], $callable[1] ) ) {
				$class      = is_object( $callable[0] ) ? get_class( $callable[0] ) : (string) $callable[0];
				$reflection = new \ReflectionMethod( $class, (string) $callable[1] );
				$name       = $class . '::' . (string) $callable[1];
			} elseif ( $callable instanceof \Closure ) {
				$reflection = new \ReflectionFunction( $callable );
				$name       = __( 'Closure', 'wp-powersuite' );
			} else {
				return null;
			}

			$file = (string) $reflection->getFileName();
			$line = (int) $reflection->getStartLine();
			$location = $file !== ''
				? ltrim( str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $file ) ), '/' ) . ':' . $line
				: '';

			return [ 'callback' => $name, 'location' => $location ];
		} catch ( \Throwable $e ) {
			return null;
		}
	}

	public static function format_belongs_to( array $ownership ): string {
		$label = (string) ( $ownership['label'] ?? __( 'Unknown', 'wp-powersuite' ) );
		$confidence = (int) ( $ownership['confidence'] ?? 0 );

		if ( ! empty( $ownership['manual'] ) ) {
			return $label . ' *';
		}

		if ( $confidence > 0 && $confidence < 100 && ( $ownership['type'] ?? '' ) !== 'wordpress' ) {
			return sprintf( '%s (%d%%)', $label, $confidence );
		}

		return $label;
	}

	/**
	 * @return array<string, array<string, mixed>>
	 */
	public static function get_overrides(): array {
		$stored = get_option( self::OVERRIDE_OPTION, [] );

		return is_array( $stored ) ? $stored : [];
	}

	/**
	 * @param array<string, mixed> $ownership
	 */
	public static function save_override( string $item_type, string $identifier, array $ownership, array $context = [] ): bool {
		$key = self::override_key( $item_type, $identifier, $context );
		$all = self::get_overrides();

		$all[ $key ] = [
			'type'       => sanitize_key( (string) ( $ownership['type'] ?? 'unknown' ) ),
			'label'      => sanitize_text_field( (string) ( $ownership['label'] ?? '' ) ),
			'slug'       => sanitize_key( (string) ( $ownership['slug'] ?? '' ) ),
			'confidence' => 100,
			'status'     => sanitize_key( (string) ( $ownership['status'] ?? 'unknown' ) ),
			'manual'     => true,
		];

		return update_option( self::OVERRIDE_OPTION, $all, false );
	}

	public static function belongs_to_filter_options(): array {
		$options = [
			'all'       => __( 'All owners', 'wp-powersuite' ),
			'wordpress' => __( 'WordPress core', 'wp-powersuite' ),
			'plugins'   => __( 'All plugins', 'wp-powersuite' ),
			'active'    => __( 'Active plugins', 'wp-powersuite' ),
			'inactive'  => __( 'Inactive plugins', 'wp-powersuite' ),
			'themes'    => __( 'Themes', 'wp-powersuite' ),
			'orphans'   => __( 'Orphans (uninstalled)', 'wp-powersuite' ),
			'unknown'   => __( 'Unknown', 'wp-powersuite' ),
			'unscanned' => __( 'Not scanned', 'wp-powersuite' ),
		];

		$owners = Ownership_Store::distinct_owners();
		if ( $owners !== [] ) {
			$plugins = [];
			$themes  = [];
			foreach ( $owners as $owner ) {
				$key = 'owner_' . sanitize_key( (string) $owner['slug'] );
				if ( $key === 'owner_' ) {
					continue;
				}
				if ( ( $owner['type'] ?? '' ) === 'theme' ) {
					$themes[ $key ] = (string) $owner['name'];
				} else {
					$plugins[ $key ] = (string) $owner['name'];
				}
			}

			if ( $plugins !== [] ) {
				$options['__plugins'] = [
					'label'   => __( 'By plugin', 'wp-powersuite' ),
					'options' => $plugins,
				];
			}
			if ( $themes !== [] ) {
				$options['__themes'] = [
					'label'   => __( 'By theme', 'wp-powersuite' ),
					'options' => $themes,
				];
			}
		}

		return $options;
	}

	public static function matches_belongs_filter( array $ownership, string $filter ): bool {
		$filter = sanitize_key( $filter );
		if ( $filter === '' || $filter === 'all' ) {
			return true;
		}

		$type    = (string) ( $ownership['type'] ?? 'unknown' );
		$status  = (string) ( $ownership['status'] ?? '' );
		$scanned = ! empty( $ownership['scanned'] );

		if ( str_starts_with( $filter, 'owner_' ) ) {
			$want = substr( $filter, 6 );
			$slug = sanitize_key( (string) ( $ownership['slug'] ?? '' ) );

			return $want !== '' && $slug === $want && in_array( $type, [ 'plugin', 'theme' ], true );
		}

		return match ( $filter ) {
			'wordpress' => $type === 'wordpress',
			'plugins'   => $type === 'plugin',
			'themes'    => $type === 'theme',
			'orphans'   => $type === 'orphan',
			'active'    => in_array( $type, [ 'plugin', 'theme' ], true ) && $status === 'active',
			'inactive'  => in_array( $type, [ 'plugin', 'theme' ], true ) && $status === 'inactive',
			'unknown'   => $type === 'unknown' && $scanned,
			'unscanned' => ! $scanned,
			default     => true,
		};
	}

	/**
	 * Whether an item is safe to remove via bulk orphan cleanup.
	 * Only true orphans (owner plugin/theme not installed) qualify.
	 *
	 * @param array<string, mixed> $ownership
	 */
	public static function is_deletable_orphan( array $ownership ): bool {
		$type   = (string) ( $ownership['type'] ?? '' );
		$status = (string) ( $ownership['status'] ?? '' );

		if ( $type === 'orphan' ) {
			return true;
		}

		return in_array( $type, [ 'plugin', 'theme' ], true ) && $status === 'not_installed';
	}

	private static function override_key( string $item_type, string $identifier, array $context ): string {
		$blog = is_multisite() ? (int) ( $context['blog_id'] ?? get_current_blog_id() ) : 0;

		return md5( $item_type . '|' . $blog . '|' . strtolower( $identifier ) );
	}

	/**
	 * @param array<string, mixed> $data
	 * @return array<string, mixed>
	 */
	private static function normalize( array $data, bool $manual ): array {
		$type = sanitize_key( (string) ( $data['type'] ?? 'unknown' ) );
		if ( ! in_array( $type, [ 'wordpress', 'plugin', 'theme', 'orphan', 'unknown' ], true ) ) {
			$type = 'unknown';
		}

		$label = (string) ( $data['label'] ?? __( 'Unknown', 'wp-powersuite' ) );
		$slug  = sanitize_key( (string) ( $data['slug'] ?? '' ) );
		$confidence = max( 0, min( 100, (int) ( $data['confidence'] ?? 0 ) ) );
		$status = sanitize_key( (string) ( $data['status'] ?? self::status_for_slug( $type, $slug ) ) );

		$owners = is_array( $data['owners'] ?? null ) ? $data['owners'] : [
			[
				'type'       => $type,
				'label'      => $label,
				'slug'       => $slug,
				'confidence' => $confidence,
				'status'     => $status,
			],
		];

		$by = $manual ? 'manual' : sanitize_key( (string) ( $data['by'] ?? 'heuristic' ) );

		return [
			'type'       => $type,
			'label'      => $label,
			'slug'       => $slug,
			'confidence' => $confidence,
			'status'     => $status,
			'manual'     => $manual,
			'by'         => $by,
			'scanned'    => ! empty( $data['scanned'] ),
			'owners'     => $owners,
		];
	}

	private static function detect_table( string $table ): array {
		global $wpdb;

		if ( Protected_Items::is_core_table( $table ) ) {
			return self::detect_wordpress( 100 );
		}

		$prefix = (string) $wpdb->prefix;
		$suffix = str_starts_with( $table, $prefix ) ? substr( $table, strlen( $prefix ) ) : $table;
		$suffix = strtolower( $suffix );

		$known = self::match_known_table_prefix( $suffix );
		if ( $known !== null ) {
			return self::finalize_table_ownership( $known );
		}

		$direct = self::match_slug_patterns( $suffix, 92 );
		if ( ( $direct['type'] ?? '' ) !== 'unknown' ) {
			return self::finalize_table_ownership( $direct );
		}

		$segment = self::match_table_segments( $suffix );
		if ( $segment !== null ) {
			return self::finalize_table_ownership( $segment );
		}

		$inactive = self::detect_inactive_or_orphan_table( $suffix );
		if ( $inactive !== null ) {
			return self::finalize_table_ownership( $inactive );
		}

		return self::finalize_table_ownership( self::detect_custom_table( $suffix ) );
	}

	/**
	 * @param array<string, mixed> $detected
	 * @return array<string, mixed>
	 */
	private static function finalize_table_ownership( array $detected ): array {
		return self::finalize_addon_ownership( $detected );
	}

	/**
	 * Mark ownership from uninstalled plugins/themes as orphan.
	 *
	 * Inactive-but-installed add-ons are NOT orphans — their tables/options must
	 * remain so reactivation works. Only not_installed owners become orphans.
	 *
	 * @param array<string, mixed> $detected
	 * @return array<string, mixed>
	 */
	private static function finalize_addon_ownership( array $detected ): array {
		$type   = (string) ( $detected['type'] ?? '' );
		$status = (string) ( $detected['status'] ?? '' );

		if ( in_array( $type, [ 'plugin', 'theme' ], true ) && $status === 'not_installed' ) {
			return self::mark_as_orphan( $detected );
		}

		return $detected;
	}

	/**
	 * @param array<string, mixed> $detected
	 * @return array<string, mixed>
	 */
	private static function mark_as_orphan( array $detected, string $label = '' ): array {
		$previous = (string) ( $detected['label'] ?? '' );

		$detected['type'] = 'orphan';

		if ( $label !== '' ) {
			$detected['label'] = $label;
		} elseif ( $previous !== '' && ! in_array( $previous, [ __( 'Unknown', 'wp-powersuite' ), __( 'Custom table', 'wp-powersuite' ), __( 'Orphan', 'wp-powersuite' ) ], true ) ) {
			$detected['label'] = sprintf(
				/* translators: %s: previous owner label */
				__( 'Orphan (%s)', 'wp-powersuite' ),
				$previous
			);
		} else {
			$detected['label'] = __( 'Orphan', 'wp-powersuite' );
		}

		return $detected;
	}

	/**
	 * @return list<string>
	 */
	private static function core_cron_hooks(): array {
		return [
			'wp_version_check',
			'wp_update_plugins',
			'wp_update_themes',
			'wp_maybe_auto_update',
			'wp_scheduled_delete',
			'delete_expired_transients',
			'wp_privacy_delete_old_export_files',
			'recovery_mode_clean_expired_keys',
			'wp_site_health_scheduled_check',
			'wp_scheduled_auto_draft_delete',
			'wp_delete_temp_updater_backups',
			'wp_https_detection',
			'wp_update_user_counts',
		];
	}

	private static function is_core_cron_hook( string $hook ): bool {
		if ( in_array( $hook, self::core_cron_hooks(), true ) ) {
			return true;
		}

		if ( str_starts_with( $hook, 'wp_privacy_' ) ) {
			return true;
		}

		return false;
	}

	private static function detect_option( string $name ): array {
		if ( Protected_Items::is_protected_option( $name ) || Protected_Items::is_protected_transient( $name ) ) {
			return self::detect_wordpress( 95 );
		}

		$clean = $name;
		if ( str_starts_with( $clean, '_transient_' ) ) {
			$clean = substr( $clean, 11 );
		} elseif ( str_starts_with( $clean, '_site_transient_' ) ) {
			$clean = substr( $clean, 16 );
		} elseif ( str_starts_with( $clean, '_transient_timeout_' ) ) {
			$clean = substr( $clean, 19 );
		} elseif ( str_starts_with( $clean, '_site_transient_timeout_' ) ) {
			$clean = substr( $clean, 24 );
		}

		$match = self::match_slug_patterns( $clean, 85 );

		if ( ( $match['type'] ?? '' ) === 'unknown' ) {
			return $match;
		}

		// Only treat an option as belonging to (and therefore an orphan of) a
		// plugin/theme when its name is actually anchored to that owner's prefix.
		// This blocks substring false positives (e.g. "db_version" matching a
		// plugin whose name contains "version") from ever becoming an orphan
		// deletion target, which could otherwise remove critical core options.
		if ( ! self::identifier_anchored_to_owner( $clean, $match ) ) {
			return self::detect_unknown();
		}

		return self::finalize_addon_ownership( $match );
	}

	/**
	 * Whether an identifier starts with one of the matched owner's prefixes.
	 *
	 * @param array<string, mixed> $match
	 */
	private static function identifier_anchored_to_owner( string $identifier, array $match ): bool {
		$identifier = strtolower( $identifier );
		$type       = (string) ( $match['type'] ?? '' );
		$slug       = (string) ( $match['slug'] ?? '' );

		if ( $slug === '' || $identifier === '' ) {
			return false;
		}

		// Meta keys are commonly underscore-prefixed (e.g. "_elementor_data"), so
		// compare both the raw and underscore-stripped identifier.
		$identifiers = array_unique( [ $identifier, ltrim( $identifier, '_' ) ] );

		$aliases = [ $slug ];

		if ( $type === 'plugin' && isset( self::plugin_index()[ $slug ]['aliases'] ) ) {
			$aliases = array_merge( $aliases, (array) self::plugin_index()[ $slug ]['aliases'] );
		} elseif ( $type === 'theme' && isset( self::theme_index()[ $slug ]['aliases'] ) ) {
			$aliases = array_merge( $aliases, (array) self::theme_index()[ $slug ]['aliases'] );
		}

		foreach ( $aliases as $alias ) {
			$alias = strtolower( (string) $alias );

			$variants = array_unique(
				[
					$alias,
					str_replace( '-', '_', $alias ),
					str_replace( [ '-', '_' ], '', $alias ),
				]
			);

			foreach ( $variants as $variant ) {
				if ( strlen( $variant ) < 3 || self::is_generic_match_token( $variant ) ) {
					continue;
				}

				foreach ( $identifiers as $candidate ) {
					if ( $candidate === $variant || str_starts_with( $candidate, $variant ) ) {
						return true;
					}
				}
			}
		}

		return false;
	}

	private static function detect_meta( string $identifier, string $meta_key ): array {
		unset( $identifier );

		if ( str_starts_with( $meta_key, '_edit_' ) || in_array( $meta_key, [ '_wp_attached_file', '_wp_attachment_metadata', '_thumbnail_id' ], true ) ) {
			return self::detect_wordpress( 95 );
		}

		$match = self::match_slug_patterns( $meta_key, 80 );

		if ( ( $match['type'] ?? '' ) === 'unknown' ) {
			return $match;
		}

		// Require the meta key to actually start with the matched owner's prefix
		// (a leading underscore is allowed), blocking substring false positives.
		if ( ! self::identifier_anchored_to_owner( $meta_key, $match ) ) {
			return self::detect_unknown();
		}

		return $match;
	}

	/**
	 * @param array<string, mixed> $context
	 */
	private static function detect_cron( string $identifier, array $context ): array {
		unset( $identifier );
		$hook = (string) ( $context['hook'] ?? '' );

		if ( $hook === '' ) {
			return self::detect_unknown();
		}

		if ( str_starts_with( $hook, 'wp_' ) || str_starts_with( $hook, 'wp_privacy_' ) ) {
			return self::detect_wordpress( 90 );
		}

		return self::match_slug_patterns( $hook, 75 );
	}

	private static function detect_wordpress( int $confidence ): array {
		return [
			'type'       => 'wordpress',
			'label'      => __( 'WordPress', 'wp-powersuite' ),
			'slug'       => 'wordpress',
			'confidence' => $confidence,
			'status'     => 'active',
		];
	}

	private static function detect_unknown(): array {
		return [
			'type'       => 'unknown',
			'label'      => __( 'Unknown', 'wp-powersuite' ),
			'slug'       => '',
			'confidence' => 0,
			'status'     => 'unknown',
		];
	}

	/**
	 * Non-core table with no plugin/theme match — still a custom DB object, not truly unknown.
	 *
	 * @return array<string, mixed>
	 */
	private static function detect_custom_table( string $suffix ): array {
		unset( $suffix );

		return [
			'type'       => 'unknown',
			'label'      => __( 'Custom table', 'wp-powersuite' ),
			'slug'       => '',
			'confidence' => 15,
			'status'     => 'unknown',
		];
	}

	/**
	 * Common table prefix → plugin folder slug overrides (folder slug ≠ table prefix).
	 *
	 * @return array<string, string> prefix (with trailing underscore) => plugin slug key
	 */
	private static function known_table_prefix_map(): array {
		return [
			'wc_'                => 'woocommerce',
			'woocommerce_'       => 'woocommerce',
			'actionscheduler_'   => 'woocommerce',
			'yoast_'             => 'wordpress-seo',
			'yoast_indexable'    => 'wordpress-seo',
			'yoast_seo_'         => 'wordpress-seo',
			'rank_math_'         => 'seo-by-rank-math',
			'gf_'                => 'gravityforms',
			'edd_'               => 'easy-digital-downloads',
			'wpforms_'           => 'wpforms-lite',
			'learndash_'         => 'sfwd-lms',
			'ld_'                => 'sfwd-lms',
			'acfe_'              => 'advanced-custom-fields-pro',
			'acf_'               => 'advanced-custom-fields',
			'pmpro_'             => 'paid-memberships-pro',
			'wfls_'              => 'wordfence',
			'wfconfig'           => 'wordfence',
			'wffilemods'         => 'wordfence',
			'wpps_'              => 'WP-Powersuite',
			'wp_powersuite_'     => 'WP-Powersuite',
			'redirection_'       => 'redirection',
			'wpml_'              => 'sitepress-multilingual-cms',
			'icl_'               => 'sitepress-multilingual-cms',
			'bookly_'            => 'bookly-responsive-appointment-booking-tool',
			'frm_'               => 'formidable',
			'formidable_'        => 'formidable',
			'jet_'               => 'jetpack',
			'wc_admin_'          => 'woocommerce',
			'wcpay_'             => 'woocommerce-payments',
			'litespeed_'         => 'litespeed-cache',
			'wpo_'               => 'wp-optimize',
			'adbc_'              => 'advanced-database-cleaner',
			'elementor_'         => 'elementor',
			'wpseo_'             => 'wordpress-seo',
			'wpforms_logs'       => 'wpforms-lite',
			'wpforms_tasks_meta' => 'wpforms-lite',
			'ninja_'             => 'ninja-forms',
			'nf_'                => 'ninja-forms',
			'wpml_mails'         => 'sitepress-multilingual-cms',
			'duplicator'         => 'duplicator',
			'duplicator_pro'     => 'duplicator',
			'wpdatatables'       => 'wpdatatables',
			'wpdatatable'        => 'wpdatatables',
			'aioseo_'            => 'all-in-one-seo-pack',
			'aiowps_'            => 'all-in-one-wp-security-and-firewall',
			'wpmdb_'             => 'wp-migrate-db',
			'wp_migrate'         => 'wp-migrate-db',
			'seopress_'          => 'wp-seopress',
			'give_'              => 'give',
			'revslider_'         => 'revslider',
			'wpcode_'            => 'insert-headers-and-footers',
			'defender_'          => 'defender-security',
			'wpdef_'             => 'defender-security',
			'w3tc_'              => 'w3-total-cache',
			'wpfc_'              => 'wp-fastest-cache',
			'sg_optimizer'       => 'sg-cachepress',
			'wp_mail_smtp'       => 'wp-mail-smtp',
			'wpms_'              => 'wp-mail-smtp',
			'fluentform_'        => 'fluentform',
			'ff_'                => 'fluentform',
			'wpfront_'           => 'wpfront-user-role-editor',
			'bbpress_'           => 'bbpress',
			'bp_'                => 'buddypress',
			'wpbp_'              => 'buddypress',
			'events_'            => 'the-events-calendar',
			'tec_'               => 'the-events-calendar',
			'tribe_'             => 'the-events-calendar',
			'wpstg_'             => 'wp-staging',
			'wpstg'              => 'wp-staging',
			'hustle_'            => 'wordpress-popup',
			'wp_hotel_'          => 'wp-hotel-booking',
			'wp_hotel'           => 'wp-hotel-booking',
		];
	}

	/**
	 * @return array<string, mixed>|null
	 */
	private static function match_known_table_prefix( string $suffix ): ?array {
		$map = array_merge( self::known_table_prefix_map(), self::dynamic_table_prefixes() );
		uksort(
			$map,
			static fn( string $a, string $b ): int => strlen( $b ) <=> strlen( $a )
		);

		foreach ( $map as $table_prefix => $plugin_slug ) {
			if ( strlen( $table_prefix ) < 3 ) {
				continue;
			}

			if ( ! self::suffix_starts_with_table_prefix( $suffix, strtolower( $table_prefix ) ) ) {
				continue;
			}

			return self::plugin_ownership_from_key( (string) $plugin_slug, 95 );
		}

		return null;
	}

	/**
	 * @return array<string, mixed>
	 */
	private static function plugin_ownership_from_key( string $plugin_key, int $confidence ): array {
		$match = self::find_plugin_by_key( $plugin_key );
		if ( $match !== null ) {
			$slug   = (string) $match['slug'];
			$plugin = (array) $match['plugin'];

			return [
				'type'       => 'plugin',
				'label'      => (string) ( $plugin['name'] ?? $slug ),
				'slug'       => $slug,
				'confidence' => $confidence,
				'status'     => (string) ( $plugin['status'] ?? 'active' ),
			];
		}

		return [
			'type'       => 'plugin',
			'label'      => self::humanize_plugin_key( $plugin_key ),
			'slug'       => sanitize_key( str_replace( [ ' ', '-' ], '_', strtolower( $plugin_key ) ) ),
			'confidence' => max( 60, $confidence - 15 ),
			'status'     => 'not_installed',
		];
	}

	/**
	 * @return array{slug: string, plugin: array<string, mixed>}|null
	 */
	private static function find_plugin_by_key( string $plugin_key ): ?array {
		$needle = strtolower( trim( $plugin_key ) );
		if ( $needle === '' ) {
			return null;
		}

		$needle_compact = str_replace( [ '-', '_', ' ' ], '', $needle );

		foreach ( self::plugin_index() as $slug => $plugin ) {
			if ( strtolower( (string) $slug ) === $needle ) {
				return [
					'slug'   => (string) $slug,
					'plugin' => $plugin,
				];
			}

			$aliases = is_array( $plugin['aliases'] ?? null ) ? $plugin['aliases'] : [];
			foreach ( $aliases as $alias ) {
				$alias = strtolower( (string) $alias );
				if ( $alias === $needle || str_replace( [ '-', '_' ], '', $alias ) === $needle_compact ) {
					return [
						'slug'   => (string) $slug,
						'plugin' => $plugin,
					];
				}
			}

			$name_compact = strtolower( preg_replace( '/[^a-z0-9]+/', '', (string) ( $plugin['name'] ?? '' ) ) ?? '' );
			if ( $name_compact !== '' && ( $name_compact === $needle_compact || str_contains( $name_compact, $needle_compact ) ) ) {
				return [
					'slug'   => (string) $slug,
					'plugin' => $plugin,
				];
			}
		}

		$alias_slug = self::plugin_alias_index()[ $needle ] ?? self::plugin_alias_index()[ $needle_compact ] ?? '';
		if ( $alias_slug !== '' && isset( self::plugin_index()[ $alias_slug ] ) ) {
			return [
				'slug'   => $alias_slug,
				'plugin' => self::plugin_index()[ $alias_slug ],
			];
		}

		return null;
	}

	private static function humanize_plugin_key( string $plugin_key ): string {
		$label = trim( str_replace( [ '-', '_' ], ' ', $plugin_key ) );

		return $label !== '' ? ucwords( $label ) : __( 'Unknown plugin', 'wp-powersuite' );
	}

	private static function suffix_starts_with_table_prefix( string $suffix, string $table_prefix ): bool {
		if ( $table_prefix === '' ) {
			return false;
		}

		if ( ! str_starts_with( $suffix, $table_prefix ) ) {
			return false;
		}

		$remainder = substr( $suffix, strlen( $table_prefix ) );

		return $remainder === '' || str_starts_with( $remainder, '_' );
	}

	/**
	 * Match tables belonging to inactive (still installed) or removed plugins.
	 *
	 * An inactive-but-installed plugin keeps ownership of its table with status
	 * "inactive" so it is never offered for deletion — its data must survive a
	 * reactivation. Only tables whose owning plugin is no longer installed are
	 * returned as orphans.
	 *
	 * @return array<string, mixed>|null
	 */
	private static function detect_inactive_or_orphan_table( string $suffix ): ?array {
		foreach ( self::plugin_index() as $slug => $plugin ) {
			$status = (string) ( $plugin['status'] ?? 'active' );
			if ( $status === 'active' ) {
				continue;
			}

			if ( self::suffix_matches_plugin_aliases( $suffix, $plugin, 3 ) ) {
				return [
					'type'       => 'plugin',
					'label'      => (string) ( $plugin['name'] ?? $slug ),
					'slug'       => (string) $slug,
					'confidence' => 90,
					'status'     => $status,
				];
			}
		}

		foreach ( self::known_table_prefix_map() as $table_prefix => $plugin_key ) {
			if ( strlen( $table_prefix ) < 3 ) {
				continue;
			}

			if ( ! self::suffix_starts_with_table_prefix( $suffix, strtolower( $table_prefix ) ) ) {
				continue;
			}

			$match = self::find_plugin_by_key( (string) $plugin_key );
			if ( $match !== null ) {
				continue;
			}

			return self::mark_as_orphan(
				self::plugin_ownership_from_key( (string) $plugin_key, 88 )
			);
		}

		return null;
	}

	/**
	 * @param array<string, mixed> $plugin
	 */
	private static function suffix_matches_plugin_aliases( string $suffix, array $plugin, int $min_alias_length = 3 ): bool {
		$aliases = is_array( $plugin['aliases'] ?? null ) ? $plugin['aliases'] : [];

		foreach ( $aliases as $alias ) {
			$alias = strtolower( (string) $alias );
			if ( strlen( $alias ) < $min_alias_length ) {
				continue;
			}

			$variants = array_unique(
				array_filter(
					[
						$alias,
						str_replace( '-', '_', $alias ),
						str_replace( [ '-', '_' ], '', $alias ),
					],
					static fn( string $value ): bool => strlen( $value ) >= $min_alias_length
				)
			);

			foreach ( $variants as $variant ) {
				if ( self::suffix_starts_with_table_prefix( $suffix, $variant . '_' ) || self::suffix_starts_with_table_prefix( $suffix, $variant ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Table prefixes inferred from installed plugin slugs, text domains, and aliases.
	 *
	 * @return array<string, string>
	 */
	private static function dynamic_table_prefixes(): array {
		if ( self::$dynamic_table_prefixes !== null ) {
			return self::$dynamic_table_prefixes;
		}

		self::$dynamic_table_prefixes = [];

		foreach ( self::plugin_index() as $slug => $plugin ) {
			$prefixes = is_array( $plugin['aliases'] ?? null ) ? $plugin['aliases'] : [ (string) $slug ];
			foreach ( $prefixes as $alias ) {
				$alias = strtolower( (string) $alias );
				if ( strlen( $alias ) < 3 || self::is_generic_match_token( $alias ) ) {
					continue;
				}
				self::$dynamic_table_prefixes[ $alias . '_' ] = (string) $slug;
				if ( str_contains( $alias, '_' ) ) {
					self::$dynamic_table_prefixes[ str_replace( '_', '', $alias ) . '_' ] = (string) $slug;
				}
			}
		}

		return self::$dynamic_table_prefixes;
	}

	/**
	 * Try matching leading table name segments (e.g. my_plugin_foo → my_plugin, my).
	 *
	 * @return array<string, mixed>|null
	 */
	private static function match_table_segments( string $suffix ): ?array {
		$parts = array_values( array_filter( explode( '_', $suffix ), static fn( string $p ): bool => $p !== '' ) );
		if ( $parts === [] ) {
			return null;
		}

		$candidates = [];
		$limit      = min( 4, count( $parts ) );

		for ( $i = $limit; $i >= 1; --$i ) {
			$segment = implode( '_', array_slice( $parts, 0, $i ) );
			$match   = self::match_slug_patterns( $segment, 88 - ( $limit - $i ) * 3 );
			if ( ( $match['type'] ?? '' ) !== 'unknown' ) {
				$candidates[] = $match;
			}

			$compact = str_replace( '_', '', $segment );
			if ( $compact !== $segment && strlen( $compact ) >= 3 ) {
				$compact_match = self::match_slug_patterns( $compact, 82 - ( $limit - $i ) * 3 );
				if ( ( $compact_match['type'] ?? '' ) !== 'unknown' ) {
					$candidates[] = $compact_match;
				}
			}
		}

		if ( $candidates === [] ) {
			return null;
		}

		usort(
			$candidates,
			static fn( array $a, array $b ): int => ( $b['confidence'] ?? 0 ) <=> ( $a['confidence'] ?? 0 )
		);

		return $candidates[0];
	}

	private static function match_slug_patterns( string $haystack, int $base_confidence ): array {
		$haystack   = strtolower( $haystack );
		$candidates = [];

		foreach ( self::plugin_index() as $slug => $plugin ) {
			$aliases = is_array( $plugin['aliases'] ?? null ) ? $plugin['aliases'] : [ (string) $slug ];
			$best    = 0;

			foreach ( $aliases as $alias ) {
				$score = self::score_match( $haystack, (string) $alias, (string) ( $plugin['name'] ?? $slug ) );
				if ( $score > $best ) {
					$best = $score;
				}
			}

			if ( $best > 0 ) {
				$candidates[] = [
					'type'       => 'plugin',
					'label'      => (string) ( $plugin['name'] ?? $slug ),
					'slug'       => (string) $slug,
					'confidence' => min( 100, $base_confidence + $best ),
					'status'     => (string) ( $plugin['status'] ?? 'active' ),
				];
			}
		}

		foreach ( self::theme_index() as $slug => $theme ) {
			$aliases = is_array( $theme['aliases'] ?? null ) ? $theme['aliases'] : [ (string) $slug ];
			$best    = 0;

			foreach ( $aliases as $alias ) {
				$score = self::score_match( $haystack, (string) $alias, (string) ( $theme['name'] ?? $slug ) );
				if ( $score > $best ) {
					$best = $score;
				}
			}

			if ( $best > 0 ) {
				$candidates[] = [
					'type'       => 'theme',
					'label'      => (string) ( $theme['name'] ?? $slug ),
					'slug'       => (string) $slug,
					'confidence' => min( 100, $base_confidence + $best - 5 ),
					'status'     => (string) ( $theme['status'] ?? 'active' ),
				];
			}
		}

		if ( $candidates === [] ) {
			return self::detect_unknown();
		}

		usort(
			$candidates,
			static fn( array $a, array $b ): int => ( $b['confidence'] ?? 0 ) <=> ( $a['confidence'] ?? 0 )
		);

		$best = $candidates[0];
		$best['owners'] = array_slice( $candidates, 0, 3 );

		return $best;
	}

	private static function score_match( string $haystack, string $slug, string $name ): int {
		$slug = strtolower( $slug );
		if ( $slug === '' ) {
			return 0;
		}

		if ( self::is_generic_match_token( $slug ) ) {
			return 0;
		}

		$slug_underscore = str_replace( '-', '_', $slug );
		$slug_hyphen     = str_replace( '_', '-', $slug );
		$slug_compact    = str_replace( [ '-', '_' ], '', $slug );
		$name_slug       = strtolower( preg_replace( '/[^a-z0-9]+/', '', $name ) ?? '' );

		foreach ( array_unique( array_filter( [ $slug, $slug_underscore, $slug_hyphen, $slug_compact ] ) ) as $variant ) {
			if ( strlen( $variant ) < 3 || self::is_generic_match_token( $variant ) ) {
				continue;
			}

			if ( str_starts_with( $haystack, $variant . '_' ) || str_contains( $haystack, '_' . $variant . '_' ) ) {
				return 18;
			}
			if ( $haystack === $variant || str_starts_with( $haystack, $variant ) ) {
				return 14;
			}
			if ( str_contains( $haystack, $variant ) ) {
				return 10;
			}
		}

		if ( strlen( $name_slug ) >= 5 && ! self::is_generic_match_token( $name_slug ) && str_contains( $haystack, $name_slug ) ) {
			return 8;
		}

		$parts = explode( '_', $haystack );
		foreach ( $parts as $part ) {
			if ( strlen( $part ) < 3 || self::is_generic_match_token( $part ) ) {
				continue;
			}
			if ( $part === $slug || $part === $slug_underscore || $part === $slug_compact ) {
				return 12;
			}
			if ( strlen( $slug ) >= 4 && str_starts_with( $part, $slug ) ) {
				return 10;
			}
		}

		return 0;
	}

	private static function is_generic_match_token( string $token ): bool {
		static $stopwords = [
			'wp',
			'api',
			'app',
			'data',
			'db',
			'log',
			'logs',
			'meta',
			'temp',
			'test',
			'cache',
			'admin',
			'core',
			'main',
			'pro',
			'lite',
			'free',
		];

		return in_array( strtolower( $token ), $stopwords, true );
	}

	private static function status_for_slug( string $type, string $slug ): string {
		if ( $type === 'wordpress' ) {
			return 'active';
		}
		if ( $slug === '' ) {
			return 'unknown';
		}
		if ( $type === 'plugin' ) {
			return isset( self::plugin_index()[ $slug ] ) ? (string) self::plugin_index()[ $slug ]['status'] : 'not_installed';
		}
		if ( $type === 'theme' ) {
			return isset( self::theme_index()[ $slug ] ) ? (string) self::theme_index()[ $slug ]['status'] : 'not_installed';
		}

		return 'unknown';
	}

	/**
	 * @param array<string, mixed> $data
	 * @param string               $plugin_file
	 * @return list<string>
	 */
	private static function build_plugin_aliases( string $slug, array $data, string $plugin_file ): array {
		$aliases = [
			strtolower( $slug ),
			strtolower( str_replace( '-', '_', $slug ) ),
			strtolower( str_replace( '_', '-', $slug ) ),
			strtolower( str_replace( [ '-', '_' ], '', $slug ) ),
		];

		$text_domain = strtolower( trim( (string) ( $data['TextDomain'] ?? '' ) ) );
		if ( $text_domain !== '' ) {
			$aliases[] = $text_domain;
			$aliases[] = str_replace( '-', '_', $text_domain );
			$aliases[] = str_replace( [ '-', '_' ], '', $text_domain );
		}

		$basename = strtolower( basename( $plugin_file, '.php' ) );
		if ( $basename !== '' && $basename !== '.' ) {
			$aliases[] = $basename;
			$aliases[] = str_replace( '-', '_', $basename );
			$aliases[] = str_replace( [ '-', '_' ], '', $basename );
		}

		$name = (string) ( $data['Name'] ?? '' );
		if ( $name !== '' ) {
			$name_slug = strtolower( preg_replace( '/[^a-z0-9]+/', '', $name ) ?? '' );
			// Only use the full compacted name — never individual words like "form"
			// or "contact", which cause false ownership matches across plugins.
			if ( strlen( $name_slug ) >= 5 ) {
				$aliases[] = $name_slug;
			}
		}

		if ( str_contains( $plugin_file, '/' ) ) {
			$parent = strtolower( dirname( $plugin_file ) );
			if ( $parent !== '.' ) {
				$aliases[] = $parent;
				$aliases[] = str_replace( '-', '_', $parent );
			}
		}

		if ( str_contains( strtolower( $slug . ' ' . $text_domain . ' ' . $name ), 'powersuite' ) ) {
			$aliases[] = 'wpps';
			$aliases[] = 'wp_powersuite';
			$aliases[] = 'wp-powersuite';
		}

		return array_values( array_unique( array_filter( $aliases, static fn( string $a ): bool => strlen( $a ) >= 2 ) ) );
	}

	/**
	 * @return array<string, string>
	 */
	private static function plugin_alias_index(): array {
		if ( self::$plugin_alias_index !== null ) {
			return self::$plugin_alias_index;
		}

		self::$plugin_alias_index = [];

		foreach ( self::plugin_index() as $slug => $plugin ) {
			$aliases = is_array( $plugin['aliases'] ?? null ) ? $plugin['aliases'] : [ (string) $slug ];
			foreach ( $aliases as $alias ) {
				$alias = strtolower( (string) $alias );
				if ( strlen( $alias ) < 2 ) {
					continue;
				}
				self::$plugin_alias_index[ $alias ] = (string) $slug;
				self::$plugin_alias_index[ str_replace( [ '-', '_' ], '', $alias ) ] = (string) $slug;
			}
		}

		return self::$plugin_alias_index;
	}

	/**
	 * @return array<string, array<string, mixed>>
	 */
	private static function plugin_index(): array {
		if ( self::$plugin_index !== null ) {
			return self::$plugin_index;
		}

		self::$plugin_index = [];
		self::$plugin_alias_index = null;
		$active = (array) get_option( 'active_plugins', [] );

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

		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$installed = get_plugins();
		foreach ( $installed as $file => $data ) {
			$slug = dirname( $file );
			if ( $slug === '.' ) {
				$slug = basename( $file, '.php' );
			}

			self::$plugin_index[ $slug ] = [
				'name'    => (string) ( $data['Name'] ?? $slug ),
				'status'  => in_array( $file, $active, true ) ? 'active' : 'inactive',
				'aliases' => self::build_plugin_aliases( $slug, $data, $file ),
			];
		}

		foreach ( get_mu_plugins() as $file => $data ) {
			$slug = basename( $file, '.php' );
			if ( $slug === '' || isset( self::$plugin_index[ $slug ] ) ) {
				continue;
			}

			self::$plugin_index[ $slug ] = [
				'name'    => (string) ( $data['Name'] ?? $slug ),
				'status'  => 'active',
				'aliases' => self::build_plugin_aliases( $slug, $data, $file ),
			];
		}

		return self::$plugin_index;
	}

	/**
	 * @return array<string, array<string, mixed>>
	 */
	private static function theme_index(): array {
		if ( self::$theme_index !== null ) {
			return self::$theme_index;
		}

		self::$theme_index = [];
		$stylesheet = (string) get_stylesheet();
		$template   = (string) get_template();
		$active     = array_unique( array_filter( [ $stylesheet, $template ] ) );

		foreach ( wp_get_themes() as $slug => $theme ) {
			$slug_key = (string) $slug;
			self::$theme_index[ $slug_key ] = [
				'name'    => (string) $theme->get( 'Name' ),
				'status'  => in_array( $slug_key, $active, true ) ? 'active' : 'inactive',
				'aliases' => array_values(
					array_unique(
						array_filter(
							[
								strtolower( $slug_key ),
								strtolower( str_replace( '-', '_', $slug_key ) ),
								strtolower( preg_replace( '/[^a-z0-9]+/', '', (string) $theme->get( 'Name' ) ) ?? '' ),
							],
							static fn( string $a ): bool => strlen( $a ) >= 2
						)
					)
				),
			];
		}

		return self::$theme_index;
	}
}
