<?php
/**
 * Scans Elementor documents (_elementor_data) and counts widget usage.
 *
 * Runs in ID-cursor batches driven by AJAX so large sites don't time out.
 * Results accumulate in a single option.
 */

defined( 'ABSPATH' ) || exit;

class TFWA_Scanner {

	const OPTION        = 'tfwa_scan_results';
	const BATCH_SIZE    = 100;
	const MAX_PAGE_REFS = 10;

	/**
	 * Number of posts carrying Elementor data (for the progress bar).
	 */
	public static function count_documents(): int {
		global $wpdb;

		return (int) $wpdb->get_var(
			"SELECT COUNT(DISTINCT p.ID)
			 FROM {$wpdb->posts} p
			 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_elementor_data'
			 WHERE p.post_status IN ( 'publish', 'private', 'draft', 'pending', 'future' )
			   AND pm.meta_value <> '' AND pm.meta_value <> '[]'"
		);
	}

	/**
	 * Reset stored results and start a fresh scan.
	 *
	 * @return string A token the caller must present on every batch, so a
	 *                concurrent scan (second admin tab, cron) cannot interleave
	 *                with this one and double-count widgets.
	 */
	public static function start(): string {
		// Lowercase: the AJAX layer sanitizes the submitted token with sanitize_key().
		$token = strtolower( wp_generate_password( 12, false ) );

		update_option(
			self::OPTION,
			array(
				'token'         => $token,
				'started'       => time(),
				'completed'     => 0,
				'last_id'       => 0,
				'posts_scanned' => 0,
				'counts'        => array(),
				'pages'         => array(),
				'doc_types'     => array(),
			),
			false
		);

		return $token;
	}

	/**
	 * Scan one batch of documents.
	 *
	 * @return array{processed:int, posts_scanned:int, done:bool}
	 */
	public static function run_batch( string $token = '' ): array {
		global $wpdb;

		$state = get_option( self::OPTION );
		if ( ! is_array( $state ) ) {
			return array(
				'processed'     => 0,
				'posts_scanned' => 0,
				'done'          => true,
				'error'         => __( 'No scan in progress. Start a new scan first.', 'tendforge-widget-auditor-for-elementor' ),
			);
		}

		if ( ! empty( $state['token'] ) && $token !== $state['token'] ) {
			return array(
				'processed'     => 0,
				'posts_scanned' => (int) $state['posts_scanned'],
				'done'          => true,
				'error'         => __( 'Another scan has taken over. Refresh the page to see its results.', 'tendforge-widget-auditor-for-elementor' ),
			);
		}

		// IDs only: heavy Elementor documents can be megabytes of JSON each, so
		// they are fetched and decoded one at a time. DISTINCT guards against
		// duplicated _elementor_data meta rows from broken imports.
		$ids = array_map(
			'intval',
			$wpdb->get_col(
				$wpdb->prepare(
					"SELECT DISTINCT p.ID
					 FROM {$wpdb->posts} p
					 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_elementor_data'
					 WHERE p.post_status IN ( 'publish', 'private', 'draft', 'pending', 'future' )
					   AND pm.meta_value <> '' AND pm.meta_value <> '[]'
					   AND p.ID > %d
					 ORDER BY p.ID ASC
					 LIMIT %d",
					(int) $state['last_id'],
					self::BATCH_SIZE
				)
			)
		);

		foreach ( $ids as $post_id ) {
			$raw      = get_post_meta( $post_id, '_elementor_data', true );
			$elements = json_decode( (string) $raw, true );
			unset( $raw );

			if ( is_array( $elements ) ) {
				self::walk( $elements, $post_id, $state );
			}
			unset( $elements );

			// Record the document type so we can prove the scan covers EVERY kind of
			// Elementor document — pages, posts, and the ones Element Manager misses:
			// popups, headers, footers, theme-builder templates, global widgets.
			$doc_type = get_post_meta( $post_id, '_elementor_template_type', true );
			if ( '' === $doc_type ) {
				$doc_type = get_post_type( $post_id ) ?: 'other';
			}
			// Elementor labels page/post documents "wp-page"/"wp-post"; collapse those
			// onto the plain post type so they don't appear as two separate rows.
			$doc_type = array( 'wp-page' => 'page', 'wp-post' => 'post' )[ $doc_type ] ?? $doc_type;

			$state['doc_types'][ $doc_type ] = ( $state['doc_types'][ $doc_type ] ?? 0 ) + 1;

			$state['last_id'] = $post_id;
			$state['posts_scanned']++;
		}

		$done = count( $ids ) < self::BATCH_SIZE;
		if ( $done ) {
			$state['completed']     = time();
			// Measure what addons actually load on a real page (not disk size).
			// This makes loopback HTTP requests; a slow/failed/throwing loopback
			// must NEVER hold up or invalidate completion — the scan is done the
			// moment its state is written. Any failure just leaves loaded_assets
			// empty (the UI already discloses an estimate may be incomplete).
			$state['loaded_assets'] = array();
			try {
				$state['loaded_assets'] = TFWA_Footprint::measure( TFWA_Footprint::sample_urls() );
			} catch ( \Throwable $e ) {
				$state['loaded_assets'] = array();
			}
		}

		update_option( self::OPTION, $state, false );

		return array(
			'processed'     => count( $ids ),
			'posts_scanned' => (int) $state['posts_scanned'],
			'done'          => $done,
		);
	}

	/**
	 * Whether a finished scan is available to act on.
	 */
	public static function is_complete(): bool {
		$state = self::get_results();

		return is_array( $state ) && ! empty( $state['completed'] );
	}

	/**
	 * Human-readable label for an Elementor document type. Covers the document
	 * kinds Element Manager's own scan overlooks (popups, headers/footers,
	 * theme-builder templates, global widgets).
	 */
	public static function doc_type_label( string $type ): string {
		$labels = array(
			'wp-page'   => __( 'Pages', 'tendforge-widget-auditor-for-elementor' ),
			'page'      => __( 'Pages', 'tendforge-widget-auditor-for-elementor' ),
			'wp-post'   => __( 'Posts', 'tendforge-widget-auditor-for-elementor' ),
			'post'      => __( 'Posts', 'tendforge-widget-auditor-for-elementor' ),
			'popup'     => __( 'Popups', 'tendforge-widget-auditor-for-elementor' ),
			'header'    => __( 'Headers', 'tendforge-widget-auditor-for-elementor' ),
			'footer'    => __( 'Footers', 'tendforge-widget-auditor-for-elementor' ),
			'section'   => __( 'Saved sections', 'tendforge-widget-auditor-for-elementor' ),
			'container' => __( 'Saved containers', 'tendforge-widget-auditor-for-elementor' ),
			'single'    => __( 'Single templates', 'tendforge-widget-auditor-for-elementor' ),
			'archive'   => __( 'Archive templates', 'tendforge-widget-auditor-for-elementor' ),
			'loop-item' => __( 'Loop items', 'tendforge-widget-auditor-for-elementor' ),
			'widget'    => __( 'Global widgets', 'tendforge-widget-auditor-for-elementor' ),
			'product'   => __( 'Products', 'tendforge-widget-auditor-for-elementor' ),
		);

		return $labels[ $type ] ?? ucwords( str_replace( array( '-', '_' ), ' ', $type ) );
	}

	/**
	 * Recursively walk an Elementor element tree, tallying widget usage.
	 */
	private static function walk( array $elements, int $post_id, array &$state ): void {
		foreach ( $elements as $element ) {
			if ( ! is_array( $element ) ) {
				continue;
			}

			if ( isset( $element['elType'] ) && 'widget' === $element['elType'] && ! empty( $element['widgetType'] ) ) {
				$widget = (string) $element['widgetType'];

				$state['counts'][ $widget ] = ( $state['counts'][ $widget ] ?? 0 ) + 1;

				if ( ! isset( $state['pages'][ $widget ] ) ) {
					$state['pages'][ $widget ] = array();
				}
				if ( count( $state['pages'][ $widget ] ) < self::MAX_PAGE_REFS && ! in_array( $post_id, $state['pages'][ $widget ], true ) ) {
					$state['pages'][ $widget ][] = $post_id;
				}
			}

			if ( ! empty( $element['elements'] ) && is_array( $element['elements'] ) ) {
				self::walk( $element['elements'], $post_id, $state );
			}
		}
	}

	/**
	 * The stored scan state (or null when never run).
	 */
	public static function get_results(): ?array {
		$state = get_option( self::OPTION );

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

	/**
	 * Combined report: registered widgets enriched with usage, grouped by provider,
	 * plus orphans (widgets used in content but no longer registered — these
	 * render as broken or empty on the front end).
	 *
	 * @return array{providers: array, orphans: array, totals: array}
	 */
	public static function get_report(): array {
		$map     = TFWA_Widget_Map::get_map();
		$results = self::get_results();
		$counts  = $results['counts'] ?? array();
		$pages   = $results['pages'] ?? array();

		// Elementor's base control stacks (common*) and structural wrappers
		// (inner-section/section/column/container) aren't user-facing widgets;
		// counting them as "unused widgets" — or flagging a used one as an
		// orphan — only misleads. Drop them from the catalogue everywhere.
		$internal = array_flip( array( 'common', 'common-base', 'common-optimized', 'inner-section', 'section', 'column', 'container' ) );
		$map      = array_diff_key( $map, $internal );
		$counts   = array_diff_key( $counts, $internal );
		$pages    = array_diff_key( $pages, $internal );

		// Keep the catalogue to real widgets: ones Elementor shows in its panel,
		// plus anything actually placed on a page (so a used-but-hidden or
		// deprecated widget still counts as "used" rather than vanishing or
		// looking like an orphan). Hidden/deprecated and not-installed types that
		// are unused drop out, so "unused" means real, placeable widgets you
		// simply aren't using — matching what the Pro bulk action can disable.
		$panel = self::panel_widget_names();
		if ( ! empty( $panel ) ) {
			$panel = array_flip( $panel );
			$map   = array_filter(
				$map,
				fn( $name ) => isset( $panel[ $name ] ) || isset( $counts[ $name ] ),
				ARRAY_FILTER_USE_KEY
			);
		}

		$providers = array();
		foreach ( $map as $name => $widget ) {
			$slug = $widget['provider_slug'];

			if ( ! isset( $providers[ $slug ] ) ) {
				$providers[ $slug ] = array(
					'slug'    => $slug,
					'label'   => $widget['provider'],
					'widgets' => array(),
					'used'    => 0,
					'unused'  => 0,
				);
			}

			$count = (int) ( $counts[ $name ] ?? 0 );

			$providers[ $slug ]['widgets'][] = array(
				'name'  => $name,
				'title' => $widget['title'],
				'count' => $count,
				'pages' => array_map(
					fn( $id ) => array(
						'id'    => $id,
						'title' => get_the_title( $id ),
						'link'  => get_edit_post_link( $id, 'raw' ),
					),
					$pages[ $name ] ?? array()
				),
			);

			$providers[ $slug ][ $count > 0 ? 'used' : 'unused' ]++;
		}

		foreach ( $providers as &$provider ) {
			usort( $provider['widgets'], fn( $a, $b ) => $b['count'] <=> $a['count'] );
		}
		unset( $provider );

		// Core first, then by number of unused widgets (the actionable ones on top).
		uasort(
			$providers,
			function ( $a, $b ) {
				$a_core = in_array( $a['slug'], array( 'elementor', 'elementor-pro' ), true ) ? 1 : 0;
				$b_core = in_array( $b['slug'], array( 'elementor', 'elementor-pro' ), true ) ? 1 : 0;

				return $a_core <=> $b_core ?: $b['unused'] <=> $a['unused'];
			}
		);

		$orphans = array();
		foreach ( $counts as $name => $count ) {
			if ( ! isset( $map[ $name ] ) ) {
				$orphans[] = array(
					'name'  => $name,
					'count' => (int) $count,
					'pages' => array_map(
						fn( $id ) => array(
							'id'    => $id,
							'title' => get_the_title( $id ),
							'link'  => get_edit_post_link( $id, 'raw' ),
						),
						$pages[ $name ] ?? array()
					),
				);
			}
		}

		$registered = count( $map );
		$used       = count( array_filter( $map, fn( $w ) => ! empty( $counts[ $w['name'] ] ) ) );

		$doc_types = $results['doc_types'] ?? array();
		arsort( $doc_types );
		$documents = array();
		foreach ( $doc_types as $type => $n ) {
			$documents[] = array(
				'type'  => (string) $type,
				'label' => self::doc_type_label( (string) $type ),
				'count' => (int) $n,
			);
		}

		return array(
			'providers'     => array_values( $providers ),
			'orphans'       => $orphans,
			'documents'     => $documents,
			'loaded_assets' => $results['loaded_assets'] ?? array(),
			'totals'        => array(
				'registered'    => $registered,
				'used'          => $used,
				'unused'        => $registered - $used,
				'posts_scanned' => (int) ( $results['posts_scanned'] ?? 0 ),
				'completed'     => (int) ( $results['completed'] ?? 0 ),
			),
		);
	}

	/**
	 * Names of widgets Elementor shows in its panel — the real, placeable set,
	 * excluding base control stacks (common*, which return show_in_panel() false)
	 * and structural primitives (not widget types at all). Returns [] when
	 * Elementor isn't loaded, so callers leave the catalogue untouched.
	 *
	 * @return string[]
	 */
	private static function panel_widget_names(): array {
		if ( ! class_exists( '\Elementor\Plugin' ) || ! isset( \Elementor\Plugin::$instance->widgets_manager ) ) {
			return array();
		}

		$names = array();
		foreach ( \Elementor\Plugin::$instance->widgets_manager->get_widget_types() as $name => $widget ) {
			if ( is_object( $widget ) && method_exists( $widget, 'show_in_panel' ) && $widget->show_in_panel() ) {
				$names[] = (string) $name;
			}
		}

		return $names;
	}
}
