<?php
/**
 * Livada SEO — WP-CLI Commands
 *
 * Registers the `livada-seo` top-level command group with subcommands for
 * managing settings, post meta, redirects, SEOPress import, sitemap cache,
 * and on-demand SEO analysis.
 *
 * Usage examples:
 *   wp livada-seo settings
 *   wp livada-seo settings --action=set --key=separator --value="-"
 *   wp livada-seo meta --post_id=42
 *   wp livada-seo meta --post_id=42 --action=set --title="My Title" --description="My desc"
 *   wp livada-seo redirects
 *   wp livada-seo redirects --action=add --source=/old --target=https://example.com --type=301
 *   wp livada-seo import seopress
 *   wp livada-seo sitemap --action=flush
 *   wp livada-seo analyze --post_id=42
 *
 * @package Livada_SEO
 */

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

if ( ! defined( 'WP_CLI' ) ) {
	return;
}

/**
 * Manages Livada SEO settings, meta, redirects, sitemap cache, and analysis.
 */
class Livada_SEO_CLI {

	// ── Bootstrap ─────────────────────────────────────────────────────────────

	/**
	 * Registers the WP-CLI command group.
	 * Called once by Livada_SEO_Loader::load_class() when WP_CLI is active.
	 */
	public static function init(): void {
		WP_CLI::add_command( 'livada-seo', self::class );
	}

	// ── settings ──────────────────────────────────────────────────────────────

	/**
	 * Gets or sets plugin settings.
	 *
	 * ## OPTIONS
	 *
	 * [--action=<action>]
	 * : Action to perform. Accepts `get` or `set`. Default: get.
	 *
	 * [--key=<key>]
	 * : Setting key to update. Required when --action=set.
	 *
	 * [--value=<value>]
	 * : New value for the setting key. Required when --action=set.
	 *
	 * ## EXAMPLES
	 *
	 *   wp livada-seo settings
	 *   wp livada-seo settings --action=get
	 *   wp livada-seo settings --action=set --key=separator --value="-"
	 *
	 * @subcommand settings
	 * @synopsis [--action=<action>] [--key=<key>] [--value=<value>]
	 *
	 * @param array $args       Positional arguments (unused).
	 * @param array $assoc_args Named arguments.
	 */
	public function settings( array $args, array $assoc_args ): void {
		$action = WP_CLI\Utils\get_flag_value( $assoc_args, 'action', 'get' );

		if ( 'set' === $action ) {
			$this->settings_set( $assoc_args );
			return;
		}

		// Default: get.
		$this->settings_get();
	}

	/**
	 * Displays all plugin settings as a formatted table.
	 */
	private function settings_get(): void {
		$settings = Livada_SEO_Loader::get_settings();

		$rows = [];
		foreach ( $settings as $key => $value ) {
			$rows[] = [
				'key'   => $key,
				'value' => is_array( $value ) ? implode( ', ', $value ) : (string) $value,
			];
		}

		WP_CLI\Utils\format_items( 'table', $rows, [ 'key', 'value' ] );
	}

	/**
	 * Updates a single setting key and saves it to the database.
	 *
	 * @param array $assoc_args Named CLI arguments (expects `key` and `value`).
	 */
	private function settings_set( array $assoc_args ): void {
		$key   = WP_CLI\Utils\get_flag_value( $assoc_args, 'key', '' );
		$value = WP_CLI\Utils\get_flag_value( $assoc_args, 'value', null );

		if ( '' === $key ) {
			WP_CLI::error( 'Please provide --key when using --action=set.' );
		}

		if ( null === $value ) {
			WP_CLI::error( 'Please provide --value when using --action=set.' );
		}

		// Retrieve current saved settings (raw, not merged with defaults).
		$saved = get_option( 'livada_seo_settings', [] );
		if ( ! is_array( $saved ) ) {
			$saved = [];
		}

		// Sanitize: arrays (comma-separated) or plain text.
		$defaults = Livada_SEO_Loader::get_settings();
		if ( isset( $defaults[ $key ] ) && is_array( $defaults[ $key ] ) ) {
			// The default for this key is an array: accept comma-separated input.
			$sanitized = array_filter(
				array_map( 'sanitize_text_field', explode( ',', (string) $value ) )
			);
		} else {
			$sanitized = sanitize_text_field( (string) $value );
		}

		$saved[ $key ] = $sanitized;

		update_option( 'livada_seo_settings', $saved, false );

		WP_CLI::success( sprintf( 'Setting "%s" updated.', $key ) );
	}

	// ── meta ──────────────────────────────────────────────────────────────────

	/**
	 * Gets or sets SEO meta for a post.
	 *
	 * ## OPTIONS
	 *
	 * --post_id=<post_id>
	 * : The post ID to work with.
	 *
	 * [--action=<action>]
	 * : Action to perform. Accepts `get` or `set`. Default: get.
	 *
	 * [--title=<title>]
	 * : SEO title to set. Used with --action=set.
	 *
	 * [--description=<description>]
	 * : Meta description to set. Used with --action=set.
	 *
	 * ## EXAMPLES
	 *
	 *   wp livada-seo meta --post_id=42
	 *   wp livada-seo meta --post_id=42 --action=get
	 *   wp livada-seo meta --post_id=42 --action=set --title="My SEO Title" --description="My description"
	 *
	 * @subcommand meta
	 * @synopsis --post_id=<post_id> [--action=<action>] [--title=<title>] [--description=<description>]
	 *
	 * @param array $args       Positional arguments (unused).
	 * @param array $assoc_args Named arguments.
	 */
	public function meta( array $args, array $assoc_args ): void {
		$post_id = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'post_id', 0 );

		if ( $post_id < 1 ) {
			WP_CLI::error( 'Please provide a valid --post_id.' );
		}

		if ( ! get_post( $post_id ) ) {
			WP_CLI::error( sprintf( 'Post %d not found.', $post_id ) );
		}

		$action = WP_CLI\Utils\get_flag_value( $assoc_args, 'action', 'get' );

		if ( 'set' === $action ) {
			$this->meta_set( $post_id, $assoc_args );
			return;
		}

		// Default: get.
		$this->meta_get( $post_id );
	}

	/**
	 * Displays all `_livada_seo_*` meta for a post as a formatted table.
	 *
	 * @param int $post_id
	 */
	private function meta_get( int $post_id ): void {
		$all_meta = get_post_meta( $post_id );

		if ( empty( $all_meta ) ) {
			WP_CLI::line( sprintf( 'No meta found for post %d.', $post_id ) );
			return;
		}

		$rows = [];
		foreach ( $all_meta as $key => $values ) {
			if ( strpos( $key, '_livada_seo_' ) !== 0 ) {
				continue;
			}

			$raw = maybe_unserialize( $values[0] ?? '' );
			$rows[] = [
				'key'   => $key,
				'value' => is_array( $raw ) ? wp_json_encode( $raw ) : (string) $raw,
			];
		}

		if ( empty( $rows ) ) {
			WP_CLI::line( sprintf( 'No _livada_seo_* meta found for post %d.', $post_id ) );
			return;
		}

		WP_CLI\Utils\format_items( 'table', $rows, [ 'key', 'value' ] );
	}

	/**
	 * Updates one or more SEO meta fields for a post.
	 *
	 * Accepts `--title` and `--description` flags.
	 *
	 * @param int   $post_id
	 * @param array $assoc_args
	 */
	private function meta_set( int $post_id, array $assoc_args ): void {
		$updated = 0;

		if ( isset( $assoc_args['title'] ) ) {
			update_post_meta(
				$post_id,
				'_livada_seo_title',
				sanitize_text_field( (string) $assoc_args['title'] )
			);
			$updated++;
			WP_CLI::line( 'Updated: _livada_seo_title' );
		}

		if ( isset( $assoc_args['description'] ) ) {
			update_post_meta(
				$post_id,
				'_livada_seo_description',
				sanitize_textarea_field( (string) $assoc_args['description'] )
			);
			$updated++;
			WP_CLI::line( 'Updated: _livada_seo_description' );
		}

		if ( $updated === 0 ) {
			WP_CLI::warning( 'No fields specified. Use --title and/or --description.' );
			return;
		}

		WP_CLI::success( sprintf( 'Updated %d meta field(s) for post %d.', $updated, $post_id ) );
	}

	// ── redirects ─────────────────────────────────────────────────────────────

	/**
	 * Lists or adds redirects.
	 *
	 * ## OPTIONS
	 *
	 * [--action=<action>]
	 * : Action to perform. Accepts `list` or `add`. Default: list.
	 *
	 * [--source=<source>]
	 * : Source path for the redirect (e.g. /old-page/). Required for --action=add.
	 *
	 * [--target=<target>]
	 * : Target URL for the redirect. Required for --action=add.
	 *
	 * [--type=<type>]
	 * : HTTP status code. Accepts 301 or 302. Default: 301.
	 *
	 * ## EXAMPLES
	 *
	 *   wp livada-seo redirects
	 *   wp livada-seo redirects --action=list
	 *   wp livada-seo redirects --action=add --source=/old --target=https://example.com
	 *   wp livada-seo redirects --action=add --source=/old --target=https://example.com --type=302
	 *
	 * @subcommand redirects
	 * @synopsis [--action=<action>] [--source=<source>] [--target=<target>] [--type=<type>]
	 *
	 * @param array $args       Positional arguments (unused).
	 * @param array $assoc_args Named arguments.
	 */
	public function redirects( array $args, array $assoc_args ): void {
		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			WP_CLI::error( 'The redirects module is not active. Enable it in Livada SEO settings.' );
		}

		$action = WP_CLI\Utils\get_flag_value( $assoc_args, 'action', 'list' );

		if ( 'add' === $action ) {
			$this->redirects_add( $assoc_args );
			return;
		}

		// Default: list.
		$this->redirects_list();
	}

	/**
	 * Outputs all redirects as a formatted table.
	 */
	private function redirects_list(): void {
		$result = Livada_SEO_Redirects::get_redirects_paginated( 1, 500 );
		$items  = $result['items'];
		$total  = $result['total'];

		if ( empty( $items ) ) {
			WP_CLI::line( 'No redirects found.' );
			return;
		}

		$rows = [];
		foreach ( $items as $item ) {
			$rows[] = [
				'id'     => $item->id,
				'source' => $item->source_url,
				'target' => $item->target_url,
				'type'   => $item->type,
				'hits'   => $item->hits,
			];
		}

		WP_CLI\Utils\format_items( 'table', $rows, [ 'id', 'source', 'target', 'type', 'hits' ] );

		if ( $total > 500 ) {
			WP_CLI::warning( sprintf( 'Showing first 500 of %d total redirects.', $total ) );
		}
	}

	/**
	 * Adds a new redirect entry.
	 *
	 * @param array $assoc_args Named CLI arguments (expects `source`, `target`, and optionally `type`).
	 */
	private function redirects_add( array $assoc_args ): void {
		$source = WP_CLI\Utils\get_flag_value( $assoc_args, 'source', '' );
		$target = WP_CLI\Utils\get_flag_value( $assoc_args, 'target', '' );
		$type   = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'type', 301 );

		if ( '' === $source ) {
			WP_CLI::error( 'Please provide --source when using --action=add.' );
		}

		if ( '' === $target ) {
			WP_CLI::error( 'Please provide --target when using --action=add.' );
		}

		if ( ! in_array( $type, [ 301, 302 ], true ) ) {
			WP_CLI::error( 'Invalid --type. Accepted values: 301, 302.' );
		}

		$id = Livada_SEO_Redirects::add_redirect( $source, $target, $type );

		if ( false === $id ) {
			WP_CLI::error( sprintf( 'Failed to add redirect for "%s". The source may already exist.', $source ) );
		}

		WP_CLI::success( sprintf( 'Redirect added (ID: %d): %s → %s [%d]', $id, $source, $target, $type ) );
	}

	// ── import ────────────────────────────────────────────────────────────────

	/**
	 * Imports data from a supported SEO plugin.
	 *
	 * ## OPTIONS
	 *
	 * <plugin>
	 * : The plugin to import from. Currently supports: seopress.
	 *
	 * ## EXAMPLES
	 *
	 *   wp livada-seo import seopress
	 *
	 * @subcommand import
	 * @synopsis <plugin>
	 *
	 * @param array $args       Positional arguments. $args[0] = plugin name.
	 * @param array $assoc_args Named arguments (unused).
	 */
	public function import( array $args, array $assoc_args ): void {
		$plugin = isset( $args[0] ) ? strtolower( sanitize_key( $args[0] ) ) : '';

		if ( 'seopress' !== $plugin ) {
			WP_CLI::error( sprintf( '"%s" is not a supported import source. Supported: seopress.', $plugin ) );
		}

		$this->import_seopress();
	}

	/**
	 * Runs the full SEOPress → Livada SEO post-meta import using WP-CLI progress bar.
	 *
	 * Processes posts in batches of 50 (matching Livada_SEO_Import::BATCH_SIZE).
	 * After post meta, imports SEOPress redirections.
	 */
	private function import_seopress(): void {
		global $wpdb;

		// Count importable posts using the same logic as the AJAX handler.
		$meta_map = [
			'_seopress_titles_title',
			'_seopress_titles_desc',
			'_seopress_analysis_target_kw',
			'_seopress_robots_index',
			'_seopress_robots_follow',
			'_seopress_robots_canonical',
			'_seopress_social_fb_img',
		];

		$placeholders = implode( ', ', array_fill( 0, count( $meta_map ), '%s' ) );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$total = (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				"SELECT COUNT(DISTINCT p.ID)
				 FROM {$wpdb->posts} p
				 WHERE p.post_status NOT IN ('auto-draft','trash')
				   AND p.post_type NOT IN ('revision','attachment')
				   AND EXISTS (
				       SELECT 1
				       FROM {$wpdb->postmeta} pm
				       WHERE pm.post_id = p.ID
				         AND pm.meta_key IN ({$placeholders})
				   )",
				...$meta_map
			)
		);

		WP_CLI::line( sprintf( 'Found %d post(s) with SEOPress meta to import.', $total ) );

		if ( $total === 0 ) {
			WP_CLI::warning( 'No SEOPress post meta found. Skipping post meta import.' );
		} else {
			$batch_size = 50;
			$progress   = WP_CLI\Utils\make_progress_bar( 'Importing SEOPress data', $total );
			$offset     = 0;
			$processed  = 0;

			while ( $offset < $total ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
						"SELECT DISTINCT p.ID
						 FROM {$wpdb->posts} p
						 WHERE p.post_status NOT IN ('auto-draft','trash')
						   AND p.post_type NOT IN ('revision','attachment')
						   AND EXISTS (
						       SELECT 1
						       FROM {$wpdb->postmeta} pm
						       WHERE pm.post_id = p.ID
						         AND pm.meta_key IN ({$placeholders})
						   )
						 ORDER BY p.ID ASC
						 LIMIT %d OFFSET %d",
						...$meta_map,
						$batch_size,
						$offset
					)
				);

				if ( empty( $rows ) ) {
					break;
				}

				foreach ( $rows as $row ) {
					$this->migrate_seopress_post_meta( (int) $row->ID );
					$progress->tick();
					$processed++;
				}

				$offset += count( $rows );

				// Stop if the last batch was smaller than batch_size.
				if ( count( $rows ) < $batch_size ) {
					break;
				}
			}

			$progress->finish();
			WP_CLI::success( sprintf( 'Imported SEOPress meta for %d post(s).', $processed ) );
		}

		// Import redirections.
		WP_CLI::line( 'Importing SEOPress redirections...' );
		$redir_result = $this->import_seopress_redirections();
		WP_CLI::success(
			sprintf(
				'Redirections: %d imported, %d skipped.',
				$redir_result['imported'],
				$redir_result['skipped']
			)
		);

		foreach ( $redir_result['warnings'] as $warning ) {
			WP_CLI::warning( $warning );
		}
	}

	/**
	 * Copies SEOPress meta values to Livada SEO meta keys for a single post.
	 * Mirror of Livada_SEO_Import::migrate_post_meta() — called directly to
	 * avoid AJAX-only coupling.
	 *
	 * @param int $post_id
	 */
	private function migrate_seopress_post_meta( int $post_id ): void {
		$meta_map = [
			'_seopress_titles_title'        => '_livada_seo_title',
			'_seopress_titles_desc'         => '_livada_seo_description',
			'_seopress_analysis_target_kw'  => '_livada_seo_focus_keyword',
			'_seopress_robots_index'        => '_livada_seo_noindex',
			'_seopress_robots_follow'       => '_livada_seo_nofollow',
			'_seopress_robots_canonical'    => '_livada_seo_canonical',
			'_seopress_social_fb_img'       => '_livada_seo_og_image',
		];

		$url_fields = [
			'_seopress_robots_canonical',
			'_seopress_social_fb_img',
		];

		foreach ( $meta_map as $seopress_key => $livada_key ) {
			$raw = get_post_meta( $post_id, $seopress_key, true );

			if ( '' === $raw || false === $raw ) {
				continue;
			}

			$is_url = in_array( $seopress_key, $url_fields, true );

			if ( $is_url ) {
				$sanitized = esc_url_raw( (string) $raw );
				if ( '' === $sanitized ) {
					continue;
				}
			} else {
				$sanitized = sanitize_text_field( (string) $raw );
				if ( '' === $sanitized && '' !== (string) $raw ) {
					continue;
				}
			}

			update_post_meta( $post_id, $livada_key, $sanitized );
		}
	}

	/**
	 * Imports SEOPress `seopress_404` CPT redirections.
	 * Mirror of Livada_SEO_Import::handle_redirections() for CLI context.
	 *
	 * @return array{imported:int,skipped:int,warnings:string[]}
	 */
	private function import_seopress_redirections(): array {
		$imported = 0;
		$skipped  = 0;
		$warnings = [];

		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			return [
				'imported' => 0,
				'skipped'  => 0,
				'warnings' => [ 'Redirects module unavailable — enable it in Livada SEO settings.' ],
			];
		}

		$query = new WP_Query( [
			'post_type'      => 'seopress_404',
			'post_status'    => 'publish',
			'posts_per_page' => -1,
			'fields'         => 'ids',
			'no_found_rows'  => true,
		] );

		foreach ( $query->posts as $post_id ) {
			$post_id = (int) $post_id;
			$post    = get_post( $post_id );

			if ( ! $post ) {
				continue;
			}

			$source     = '/' . ltrim( $post->post_name, '/' );
			$raw_target = get_post_meta( $post_id, '_seopress_redirections_value', true );
			$raw_type   = get_post_meta( $post_id, '_seopress_redirections_type', true );
			$type       = in_array( (int) $raw_type, [ 301, 302 ], true ) ? (int) $raw_type : 301;

			if ( empty( $raw_target ) ) {
				$skipped++;
				$warnings[] = sprintf( 'Redirection post %d: missing target URL, skipped.', $post_id );
				continue;
			}

			$target = esc_url_raw( (string) $raw_target );

			if ( empty( $target ) ) {
				$skipped++;
				$warnings[] = sprintf( 'Redirection post %d: invalid target URL, skipped.', $post_id );
				continue;
			}

			$result = Livada_SEO_Redirects::add_redirect( $source, $target, $type );

			if ( false === $result ) {
				$skipped++;
				$warnings[] = sprintf(
					'Redirection post %d: could not insert source "%s" (may already exist).',
					$post_id,
					$source
				);
			} else {
				$imported++;
			}
		}

		return compact( 'imported', 'skipped', 'warnings' );
	}

	// ── sitemap ───────────────────────────────────────────────────────────────

	/**
	 * Manages the sitemap cache and diagnoses sitemap configuration.
	 *
	 * ## OPTIONS
	 *
	 * [--action=<action>]
	 * : Action to perform. Accepts: flush, diagnose. Default: flush.
	 *
	 * [--type=<type>]
	 * : When --action=flush, restrict to a single sub-sitemap type (e.g. news, post, page, category).
	 *
	 * ## EXAMPLES
	 *
	 *   wp livada-seo sitemap --action=flush
	 *   wp livada-seo sitemap --action=flush --type=news
	 *   wp livada-seo sitemap --action=diagnose
	 *
	 * @subcommand sitemap
	 * @synopsis [--action=<action>] [--type=<type>]
	 *
	 * @param array $args       Positional arguments (unused).
	 * @param array $assoc_args Named arguments.
	 */
	public function sitemap( array $args, array $assoc_args ): void {
		$action = WP_CLI\Utils\get_flag_value( $assoc_args, 'action', 'flush' );

		if ( 'flush' === $action ) {
			$type = WP_CLI\Utils\get_flag_value( $assoc_args, 'type', '' );
			$this->sitemap_flush( (string) $type );
			return;
		}

		if ( 'diagnose' === $action ) {
			$this->sitemap_diagnose();
			return;
		}

		WP_CLI::error( sprintf( 'Unknown action "%s". Supported: flush, diagnose.', $action ) );
	}

	/**
	 * Runs a full diagnostic of the sitemap configuration. Lists every public
	 * post type registered on the site, flags the ones that look like
	 * page-builder templates (Elementor, JetEngine, ElementsKit, etc.), and
	 * tells the operator which CPTs are about to land in the sitemap.
	 *
	 * Useful right after a Yoast → Livada migration to confirm that template
	 * post types (jkit-header, jkit-footer, elementor_library, …) are NOT
	 * exposed publicly anymore.
	 */
	private function sitemap_diagnose(): void {
		if ( ! class_exists( 'Livada_SEO_Sitemap' ) ) {
			WP_CLI::error( 'The sitemap module is not active. Enable it in Livada SEO settings.' );
		}

		$configured = Livada_SEO_Loader::get_setting( 'sitemap_post_types', [ 'post', 'page' ] );
		$configured = is_array( $configured ) ? $configured : [ 'post', 'page' ];

		$deny       = Livada_SEO_Sitemap::get_template_cpts();
		$emitted    = array_values( array_diff( $configured, $deny ) );
		$stripped   = array_values( array_intersect( $configured, $deny ) );

		// Public post types currently registered on the site.
		$registered = get_post_types( [ 'public' => true ], 'objects' );
		unset( $registered['attachment'] );

		$rows = [];
		foreach ( $registered as $slug => $obj ) {
			$is_in_settings = in_array( $slug, $configured, true );
			$is_template    = in_array( $slug, $deny, true ) || self::looks_like_template_cpt( $slug );

			if ( $is_in_settings && ! in_array( $slug, $emitted, true ) ) {
				$status = 'BLOCKED (template deny-list)';
			} elseif ( $is_in_settings ) {
				$status = 'in sitemap';
			} elseif ( $is_template ) {
				$status = 'template — correctly excluded';
			} else {
				$status = 'not in sitemap';
			}

			$rows[] = [
				'slug'     => $slug,
				'label'    => isset( $obj->labels->name ) ? (string) $obj->labels->name : '',
				'count'    => (int) wp_count_posts( $slug )->publish,
				'template' => $is_template ? 'yes' : 'no',
				'status'   => $status,
			];
		}

		WP_CLI::line( '' );
		WP_CLI::line( WP_CLI::colorize( '%4 Public post types %n' ) );
		WP_CLI\Utils\format_items( 'table', $rows, [ 'slug', 'label', 'count', 'template', 'status' ] );

		WP_CLI::line( '' );
		WP_CLI::line( WP_CLI::colorize( '%4 Sitemap deny-list (template-only CPTs auto-stripped) %n' ) );
		WP_CLI::line( implode( ', ', $deny ) );

		WP_CLI::line( '' );
		WP_CLI::line( WP_CLI::colorize( '%4 Final post types emitted in sitemap %n' ) );
		WP_CLI::line( $emitted ? implode( ', ', $emitted ) : '(none)' );

		if ( ! empty( $stripped ) ) {
			WP_CLI::line( '' );
			WP_CLI::warning( sprintf(
				'Stripped from your settings because they are templates: %s. Remove them from sitemap_post_types to clean up settings.',
				implode( ', ', $stripped )
			) );
		}

		// Heuristic: warn about non-deny-listed CPTs whose slug *looks like* a template.
		$suspicious = [];
		foreach ( $registered as $slug => $obj ) {
			if ( in_array( $slug, $deny, true ) ) {
				continue;
			}
			if ( self::looks_like_template_cpt( $slug ) && ! in_array( $slug, [ 'post', 'page' ], true ) ) {
				$suspicious[] = $slug;
			}
		}

		if ( ! empty( $suspicious ) ) {
			WP_CLI::line( '' );
			WP_CLI::warning( sprintf(
				'These CPTs look like templates but are not in the deny-list: %s. Append them via the livada_seo_sitemap_template_cpts filter if they hold no public content.',
				implode( ', ', $suspicious )
			) );
		}

		WP_CLI::line( '' );
		WP_CLI::success( 'Diagnostic complete.' );
	}

	/**
	 * Heuristic match for slugs that smell like template / library / partial CPTs.
	 */
	private static function looks_like_template_cpt( string $slug ): bool {
		$slug = strtolower( $slug );

		$prefixes = [ 'jkit-', 'jet-', 'elementor', 'oceanwp_', 'astra-', 'tcb_', 'fl-', 'cartflows_', 'brizy-' ];
		foreach ( $prefixes as $p ) {
			if ( 0 === strpos( $slug, $p ) ) {
				return true;
			}
		}

		$contains = [ 'template', 'library', 'layout', 'partial', 'header', 'footer', 'popup', 'lightbox' ];
		foreach ( $contains as $c ) {
			if ( false !== strpos( $slug, $c ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Deletes `livada_seo_sitemap_*` transients. When `$type` is non-empty,
	 * only the matching sub-sitemap is flushed (e.g. `news`, `post`, `page`,
	 * `category`).
	 */
	private function sitemap_flush( string $type = '' ): void {
		if ( '' !== $type ) {
			$key   = 'livada_seo_sitemap_' . sanitize_key( $type );
			$found = (bool) get_transient( $key );

			delete_transient( $key );

			if ( $found ) {
				WP_CLI::success( sprintf( 'Flushed sitemap transient "%s".', $key ) );
			} else {
				WP_CLI::warning( sprintf( 'Transient "%s" was not present.', $key ) );
			}
			return;
		}

		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$deleted = $wpdb->query(
			"DELETE FROM {$wpdb->options}
			 WHERE option_name LIKE '_transient_livada_seo_sitemap_%'
			    OR option_name LIKE '_transient_timeout_livada_seo_sitemap_%'"
		);

		if ( false === $deleted ) {
			WP_CLI::error( 'Failed to flush sitemap transients.' );
		}

		// Cast to int: query() returns int|bool but false is already handled above.
		$count = (int) $deleted;

		// Each transient creates two rows (value + timeout), so divide by 2.
		$transients = (int) ceil( $count / 2 );

		WP_CLI::success( sprintf( 'Flushed %d sitemap transient(s).', $transients ) );
	}

	// ── audit ─────────────────────────────────────────────────────────────────

	/**
	 * Runs the full site audit and prints a summary + per-check breakdown.
	 *
	 * ## OPTIONS
	 *
	 * [--format=<format>]
	 * : Output format. Accepts: table, json. Default: table.
	 *
	 * [--filter=<status>]
	 * : Restrict per-post output to a status. Accepts: red, orange, green.
	 *
	 * ## EXAMPLES
	 *
	 *   wp livada-seo audit
	 *   wp livada-seo audit --filter=red
	 *   wp livada-seo audit --format=json
	 *
	 * @subcommand audit
	 * @synopsis [--format=<format>] [--filter=<status>]
	 *
	 * @param array $args       Positional arguments (unused).
	 * @param array $assoc_args Named arguments.
	 */
	public function audit( array $args, array $assoc_args ): void {
		if ( ! class_exists( 'Livada_SEO_Site_Audit' ) ) {
			WP_CLI::error( 'The Site Audit module is not active. Enable it in Livada SEO settings.' );
		}

		$format = WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' );
		$filter = WP_CLI\Utils\get_flag_value( $assoc_args, 'filter', '' );

		$results = Livada_SEO_Site_Audit::run_audit( true );

		if ( 'json' === $format ) {
			WP_CLI::line( wp_json_encode( $results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) );
			return;
		}

		// Summary.
		$summary = $results['summary'] ?? [];
		WP_CLI::line( '' );
		WP_CLI::line( WP_CLI::colorize( '%4 Summary %n' ) );
		WP_CLI::line( sprintf(
			'  Posts: %d total — %d green, %d orange, %d red',
			(int) ( $summary['total_posts']  ?? 0 ),
			(int) ( $summary['posts_green']  ?? 0 ),
			(int) ( $summary['posts_orange'] ?? 0 ),
			(int) ( $summary['posts_red']    ?? 0 )
		) );
		WP_CLI::line( sprintf(
			'  Site-wide checks: %d green, %d orange, %d red',
			(int) ( $summary['site_green']  ?? 0 ),
			(int) ( $summary['site_orange'] ?? 0 ),
			(int) ( $summary['site_red']    ?? 0 )
		) );
		WP_CLI::line( sprintf( '  Total issues: %d', (int) ( $summary['total_issues'] ?? 0 ) ) );
		WP_CLI::line( sprintf( '  Duration: %d ms', (int) ( $results['duration_ms'] ?? 0 ) ) );

		// Site-wide table.
		$site_rows = [];
		foreach ( ( $results['site_wide'] ?? [] ) as $c ) {
			$site_rows[] = [
				'check'   => isset( $c['check'] )   ? (string) $c['check']   : '',
				'status'  => isset( $c['status'] )  ? (string) $c['status']  : '',
				'message' => isset( $c['message'] ) ? (string) $c['message'] : '',
			];
		}
		WP_CLI::line( '' );
		WP_CLI::line( WP_CLI::colorize( '%4 Site-wide checks %n' ) );
		WP_CLI\Utils\format_items( 'table', $site_rows, [ 'check', 'status', 'message' ] );

		// Per-post table.
		$post_rows = [];
		foreach ( ( $results['per_post'] ?? [] ) as $row ) {
			$status = isset( $row['status'] ) ? (string) $row['status'] : '';

			if ( '' !== $filter && $filter !== $status ) {
				continue;
			}

			$failed_keys = [];
			foreach ( ( $row['checks'] ?? [] ) as $c ) {
				$cs = isset( $c['status'] ) ? (string) $c['status'] : '';
				if ( 'green' !== $cs ) {
					$failed_keys[] = (string) ( $c['check'] ?? '' );
				}
			}

			$post_rows[] = [
				'post_id' => (int) ( $row['post_id'] ?? 0 ),
				'status'  => $status,
				'title'   => isset( $row['title'] ) ? (string) $row['title'] : '',
				'issues'  => empty( $failed_keys ) ? '' : implode( ', ', $failed_keys ),
			];
		}

		WP_CLI::line( '' );
		WP_CLI::line( WP_CLI::colorize( '%4 Per-post results %n' ) );
		if ( empty( $post_rows ) ) {
			WP_CLI::line( '  (no rows match the filter)' );
		} else {
			WP_CLI\Utils\format_items( 'table', $post_rows, [ 'post_id', 'status', 'title', 'issues' ] );
		}

		WP_CLI::line( '' );
		WP_CLI::success( 'Audit complete.' );
	}

	// ── doctor ────────────────────────────────────────────────────────────────

	/**
	 * Runs a self-test of every Livada SEO subsystem and prints a summary.
	 *
	 * Useful for first-deploy QA, post-migration sanity check, or to debug a
	 * misbehaving instance. Pure local checks — no outbound HTTP unless `--ping`
	 * is passed.
	 *
	 * Checks:
	 *   1. WP version + PHP version
	 *   2. Required PHP extensions (DOM, libxml, openssl, mbstring)
	 *   3. Custom tables exist (redirects, 404 log)
	 *   4. WP-Cron events scheduled (sitemap, indexnow ping handler, audit)
	 *   5. Sitemap rewrite rules registered for the current plugin version
	 *   6. robots.txt setting contains a Sitemap directive
	 *   7. LocalBusiness or multi-location data present
	 *   8. REST API namespace responds (when --ping is set)
	 *   9. Anthropic API key encrypts/decrypts roundtrip OK
	 *  10. No conflicting SEO plugin active
	 *
	 * ## OPTIONS
	 *
	 * [--ping]
	 * : Hit the REST API namespace + the homepage to validate end-to-end. Off by default.
	 *
	 * ## EXAMPLES
	 *
	 *   wp livada-seo doctor
	 *   wp livada-seo doctor --ping
	 *
	 * @subcommand doctor
	 * @synopsis [--ping]
	 *
	 * @param array $args       Positional arguments (unused).
	 * @param array $assoc_args Named arguments.
	 */
	public function doctor( array $args, array $assoc_args ): void {
		$ping = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'ping', false );

		$checks = [];

		// ── WP / PHP versions ────────────────────────────────────────────────
		global $wp_version;
		$wp_ok  = version_compare( $wp_version, '6.0', '>=' );
		$php_ok = version_compare( PHP_VERSION, '7.4', '>=' );

		$checks[] = self::doctor_row(
			'WordPress version',
			$wp_ok ? 'pass' : 'fail',
			$wp_version . ( $wp_ok ? '' : ' (requires ≥ 6.0)' )
		);
		$checks[] = self::doctor_row(
			'PHP version',
			$php_ok ? 'pass' : 'fail',
			PHP_VERSION . ( $php_ok ? '' : ' (requires ≥ 7.4)' )
		);

		// ── Required extensions ──────────────────────────────────────────────
		foreach ( [ 'dom', 'libxml', 'mbstring', 'openssl' ] as $ext ) {
			$loaded = extension_loaded( $ext );
			$checks[] = self::doctor_row(
				"PHP ext: {$ext}",
				$loaded ? 'pass' : ( 'openssl' === $ext ? 'warn' : 'fail' ),
				$loaded ? 'loaded' : ( 'openssl' === $ext ? 'missing — AI key encryption falls back to base64' : 'missing — required' )
			);
		}

		// ── Custom tables ────────────────────────────────────────────────────
		global $wpdb;
		foreach ( [ 'livada_seo_redirects', 'livada_seo_404_log' ] as $tbl ) {
			$name   = $wpdb->prefix . $tbl;
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$exists = (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $name ) );
			$checks[] = self::doctor_row(
				"Table {$tbl}",
				'' !== $exists ? 'pass' : 'warn',
				'' !== $exists ? 'present' : 'missing — created on first need'
			);
		}

		// ── Cron events ──────────────────────────────────────────────────────
		// Only `livada_seo_cron_audit` is a recurring cron. Sitemap is a lazy
		// transient cache, IndexNow uses one-shot `livada_seo_indexnow_ping`
		// events scheduled on demand. Reporting the latter two as "not
		// scheduled" was misleading and showed up as a warning in CI.
		$audit_next = wp_next_scheduled( 'livada_seo_cron_audit' );
		$checks[]   = self::doctor_row(
			'Cron livada_seo_cron_audit',
			$audit_next ? 'pass' : 'warn',
			$audit_next ? 'next: ' . gmdate( 'Y-m-d H:i:s', (int) $audit_next ) . ' UTC' : 'not scheduled'
		);

		$ping_next = wp_next_scheduled( 'livada_seo_indexnow_ping' );
		$checks[]  = self::doctor_row(
			'IndexNow ping queue',
			'pass',
			$ping_next ? 'next: ' . gmdate( 'Y-m-d H:i:s', (int) $ping_next ) . ' UTC' : 'on demand (no ping queued)'
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$sitemap_cache = (int) $wpdb->get_var(
			"SELECT MAX(option_value) FROM {$wpdb->options}
			 WHERE option_name LIKE '_transient_timeout_livada_seo_sitemap_%'"
		);
		$checks[]      = self::doctor_row(
			'Sitemap cache',
			'pass',
			$sitemap_cache > time() ? 'fresh until ' . gmdate( 'Y-m-d H:i:s', $sitemap_cache ) . ' UTC' : 'on demand (regenerated on next request)'
		);

		// ── Sitemap rewrite freshness ────────────────────────────────────────
		$stored_v = (string) get_option( 'livada_seo_sitemap_rewrites_version', '' );
		$plug_v   = defined( 'LIVADA_SEO_VERSION' ) ? LIVADA_SEO_VERSION : '';
		$checks[] = self::doctor_row(
			'Sitemap rewrites',
			'' !== $stored_v && $stored_v === $plug_v ? 'pass' : 'warn',
			'stored ' . ( '' === $stored_v ? '(none)' : $stored_v ) . ', plugin ' . $plug_v
		);

		// ── robots.txt ───────────────────────────────────────────────────────
		$robots   = (string) Livada_SEO_Loader::get_setting( 'robots_txt', '' );
		$has_smap = false !== stripos( $robots, 'sitemap:' );
		$checks[] = self::doctor_row(
			'robots.txt',
			$has_smap ? 'pass' : 'warn',
			$has_smap ? 'Sitemap directive present' : 'Sitemap directive missing'
		);

		// ── LocalBusiness / locations ────────────────────────────────────────
		$locations = class_exists( 'Livada_SEO_Schema' ) ? Livada_SEO_Schema::get_locations() : [];
		$checks[]  = self::doctor_row(
			'LocalBusiness data',
			! empty( $locations ) ? 'pass' : 'warn',
			! empty( $locations ) ? count( $locations ) . ' location(s) configured' : 'no business data — Schema → Locations is empty'
		);

		// ── Anthropic key roundtrip ──────────────────────────────────────────
		$has_key = false;
		if ( class_exists( 'Livada_SEO_Image_Bulk' ) ) {
			$has_key = '' !== Livada_SEO_Image_Bulk::get_anthropic_key();
		}
		$checks[] = self::doctor_row(
			'Anthropic API key',
			$has_key ? 'pass' : 'info',
			$has_key ? 'present (decryptable)' : 'not configured (AI alt-text disabled)'
		);

		// ── SEO plugin conflicts ─────────────────────────────────────────────
		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		$conflicts = [];
		foreach ( [
			'wordpress-seo/wp-seo.php'                             => 'Yoast SEO',
			'wordpress-seo-premium/wp-seo-premium.php'             => 'Yoast SEO Premium',
			'seo-by-rank-math/rank-math.php'                       => 'Rank Math',
			'all-in-one-seo-pack/all_in_one_seo_pack.php'          => 'AIOSEO',
			'wp-seopress/seopress.php'                             => 'SEOPress',
		] as $slug => $label ) {
			if ( is_plugin_active( $slug ) ) {
				$conflicts[] = $label;
			}
		}
		$checks[] = self::doctor_row(
			'SEO plugin conflicts',
			empty( $conflicts ) ? 'pass' : 'warn',
			empty( $conflicts ) ? 'none' : implode( ', ', $conflicts ) . ' active alongside Livada'
		);

		// ── Optional ping ────────────────────────────────────────────────────
		if ( $ping ) {
			$endpoint = home_url( '/wp-json/livada-seo/v1/dashboard' );
			$resp     = wp_remote_get( $endpoint, [ 'timeout' => 5 ] );

			if ( is_wp_error( $resp ) ) {
				$checks[] = self::doctor_row( 'REST API ping', 'fail', $resp->get_error_message() );
			} else {
				$code = (int) wp_remote_retrieve_response_code( $resp );
				$checks[] = self::doctor_row(
					'REST API ping',
					( 200 === $code || 401 === $code ) ? 'pass' : 'warn',
					$endpoint . ' → HTTP ' . $code . ( 401 === $code ? ' (auth required is OK)' : '' )
				);
			}
		}

		// ── Print ────────────────────────────────────────────────────────────
		WP_CLI\Utils\format_items( 'table', $checks, [ 'check', 'status', 'detail' ] );

		$failed  = count( array_filter( $checks, static fn( $c ) => 'fail' === $c['status'] ) );
		$warned  = count( array_filter( $checks, static fn( $c ) => 'warn' === $c['status'] ) );

		WP_CLI::line( '' );
		if ( $failed > 0 ) {
			WP_CLI::error( sprintf( '%d critical issue(s), %d warning(s).', $failed, $warned ) );
		} elseif ( $warned > 0 ) {
			WP_CLI::warning( sprintf( '%d warning(s) — plugin is functional.', $warned ) );
		} else {
			WP_CLI::success( 'All checks passed.' );
		}
	}

	/**
	 * Builds a single doctor table row.
	 */
	private static function doctor_row( string $check, string $status, string $detail ): array {
		return [
			'check'  => $check,
			'status' => $status,
			'detail' => $detail,
		];
	}

	// ── analyze ───────────────────────────────────────────────────────────────

	/**
	 * Runs SEO analysis on a post and displays the results.
	 *
	 * ## OPTIONS
	 *
	 * --post_id=<post_id>
	 * : The post ID to analyze.
	 *
	 * ## EXAMPLES
	 *
	 *   wp livada-seo analyze --post_id=42
	 *
	 * @subcommand analyze
	 * @synopsis --post_id=<post_id>
	 *
	 * @param array $args       Positional arguments (unused).
	 * @param array $assoc_args Named arguments.
	 */
	public function analyze( array $args, array $assoc_args ): void {
		$post_id = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'post_id', 0 );

		if ( $post_id < 1 ) {
			WP_CLI::error( 'Please provide a valid --post_id.' );
		}

		if ( ! get_post( $post_id ) ) {
			WP_CLI::error( sprintf( 'Post %d not found.', $post_id ) );
		}

		if ( ! class_exists( 'Livada_SEO_Analysis' ) ) {
			WP_CLI::error( 'The analysis module is not active. Enable it in Livada SEO settings.' );
		}

		// Run analysis (also persists score meta).
		Livada_SEO_Analysis::analyze( $post_id );

		// Retrieve persisted results.
		$score   = (int) get_post_meta( $post_id, '_livada_seo_score', true );
		$details = get_post_meta( $post_id, '_livada_seo_score_details', true );

		if ( ! is_array( $details ) || empty( $details ) ) {
			WP_CLI::warning( sprintf( 'No analysis details for post %d. Ensure a focus keyword is set.', $post_id ) );
			WP_CLI::line( sprintf( 'Score: %d', $score ) );
			return;
		}

		$rows = [];
		foreach ( $details as $result ) {
			$rows[] = [
				'check'   => $result['check']   ?? '',
				'status'  => $result['status']  ?? '',
				'message' => $result['message'] ?? '',
			];
		}

		WP_CLI\Utils\format_items( 'table', $rows, [ 'check', 'status', 'message' ] );

		WP_CLI::line( '' );
		WP_CLI::line( sprintf( 'Overall SEO score for post %d: %d/100', $post_id, $score ) );
	}
}
