<?php
/**
 * Persistent ownership attribution store produced by the source scanner.
 *
 * @package DatabaseCleanup
 */

namespace DatabaseCleanup\Includes;

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

/**
 * Stores per-item ownership results (owner slug, confidence, candidates) keyed by
 * a normalized identifier, grouped by entity family. One autoload=no option per
 * group keeps lookups to a single row and avoids bloating the options cache.
 */
class Ownership_Store {

	public const OPTION_PREFIX = 'wpps_dc_owner_map_';
	public const META_OPTION   = 'wpps_dc_owner_scan_meta';
	public const MAX_ENTRIES   = 20000;

	public const GROUPS = [ 'option', 'table', 'cron', 'meta' ];

	/** @var array<string, array<string, array<string, mixed>>> */
	private static array $cache = [];

	/**
	 * Map an item type to its storage group.
	 */
	public static function group_for( string $item_type ): string {
		return match ( $item_type ) {
			'table'                                                => 'table',
			'cron'                                                 => 'cron',
			'post_meta', 'user_meta', 'comment_meta', 'term_meta'  => 'meta',
			default                                                => 'option',
		};
	}

	/**
	 * Normalize a lookup key for a group. Table suffixes are case-insensitive.
	 */
	public static function normalize_key( string $group, string $identifier ): string {
		return $group === 'table' ? strtolower( $identifier ) : $identifier;
	}

	/**
	 * @return array<string, array<string, mixed>>
	 */
	public static function all( string $group ): array {
		if ( ! in_array( $group, self::GROUPS, true ) ) {
			return [];
		}

		if ( isset( self::$cache[ $group ] ) ) {
			return self::$cache[ $group ];
		}

		$stored = get_option( self::OPTION_PREFIX . $group, [] );
		self::$cache[ $group ] = is_array( $stored ) ? $stored : [];

		return self::$cache[ $group ];
	}

	/**
	 * @return array<string, mixed>|null
	 */
	public static function get( string $group, string $identifier ): ?array {
		$entry = self::all( $group )[ self::normalize_key( $group, $identifier ) ] ?? null;

		return is_array( $entry ) ? $entry : null;
	}

	/**
	 * @param list<string> $identifiers
	 * @return array<string, array<string, mixed>>
	 */
	public static function bulk_get( string $group, array $identifiers ): array {
		$all = self::all( $group );
		$out = [];

		foreach ( $identifiers as $identifier ) {
			$key = self::normalize_key( $group, (string) $identifier );
			if ( isset( $all[ $key ] ) ) {
				$out[ (string) $identifier ] = $all[ $key ];
			}
		}

		return $out;
	}

	/**
	 * Replace an entire group's attribution map, trimming to the size cap by
	 * dropping "no owner" entries first so real attributions are preserved.
	 *
	 * @param array<string, array<string, mixed>> $entries
	 */
	public static function replace( string $group, array $entries ): bool {
		if ( ! in_array( $group, self::GROUPS, true ) ) {
			return false;
		}

		if ( count( $entries ) > self::MAX_ENTRIES ) {
			$owned   = array_filter( $entries, static fn( $e ): bool => is_array( $e ) && ( $e['t'] ?? '' ) !== 'unknown' );
			$entries = count( $owned ) > self::MAX_ENTRIES
				? array_slice( $owned, 0, self::MAX_ENTRIES, true )
				: $owned + array_slice( array_diff_key( $entries, $owned ), 0, self::MAX_ENTRIES - count( $owned ), true );
		}

		self::$cache[ $group ] = $entries;

		return update_option( self::OPTION_PREFIX . $group, $entries, false );
	}

	/**
	 * Distinct plugin/theme owners discovered by the source scanner, across all
	 * groups, sorted by attribution count (desc) then name. Used to build the
	 * "Belongs to" filter's per-owner options.
	 *
	 * @return list<array<string, mixed>>
	 */
	public static function distinct_owners(): array {
		$owners = [];

		foreach ( self::GROUPS as $group ) {
			foreach ( self::all( $group ) as $entry ) {
				if ( ! is_array( $entry ) ) {
					continue;
				}
				$type = (string) ( $entry['t'] ?? '' );
				$slug = (string) ( $entry['s'] ?? '' );
				if ( $slug === '' || ! in_array( $type, [ 'plugin', 'theme' ], true ) ) {
					continue;
				}
				$key = $type . ':' . strtolower( $slug );
				if ( ! isset( $owners[ $key ] ) ) {
					$owners[ $key ] = [
						'type'   => $type,
						'slug'   => $slug,
						'name'   => (string) ( $entry['n'] ?? $slug ),
						'status' => (string) ( $entry['st'] ?? '' ),
						'count'  => 0,
					];
				}
				++$owners[ $key ]['count'];
			}
		}

		uasort(
			$owners,
			static function ( array $a, array $b ): int {
				return ( $b['count'] <=> $a['count'] ) ?: strcasecmp( (string) $a['name'], (string) $b['name'] );
			}
		);

		return array_values( $owners );
	}

	public static function has_data(): bool {
		foreach ( self::GROUPS as $group ) {
			if ( self::all( $group ) !== [] ) {
				return true;
			}
		}

		return false;
	}

	public static function scanned_at(): int {
		$meta = get_option( self::META_OPTION, [] );

		return is_array( $meta ) ? (int) ( $meta['scanned_at'] ?? 0 ) : 0;
	}

	public static function is_stale(): bool {
		$meta = get_option( self::META_OPTION, [] );

		return is_array( $meta ) && ! empty( $meta['stale'] );
	}

	public static function mark_scanned(): void {
		update_option( self::META_OPTION, [ 'scanned_at' => time(), 'stale' => false ], false );
	}

	/**
	 * Flag the store as stale after a plugin/theme change so the UI can prompt a
	 * re-detect. Existing attributions remain usable in the meantime.
	 */
	public static function mark_stale(): void {
		if ( ! self::has_data() ) {
			return;
		}

		$meta = get_option( self::META_OPTION, [] );
		$meta = is_array( $meta ) ? $meta : [];
		$meta['stale'] = true;

		update_option( self::META_OPTION, $meta, false );
	}

	public static function clear_all(): void {
		foreach ( self::GROUPS as $group ) {
			delete_option( self::OPTION_PREFIX . $group );
			unset( self::$cache[ $group ] );
		}

		delete_option( self::META_OPTION );
	}
}
