<?php
/**
 * Livada SEO — Import Module (SEOPress)
 *
 * Provides batched AJAX-driven migration of SEOPress post meta and redirections
 * into Livada SEO's own data structures.  The admin tools page drives the
 * process via JS fetch loops; each call processes one batch of 50 posts and
 * returns JSON progress data until `done: true` is returned.
 *
 * Supported actions (POST param `action_type`):
 *   start        — reset progress, return total post count
 *   batch        — process next 50 posts, advance offset transient
 *   redirections — import all SEOPress CPT redirections in one pass
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Import {

	/** Transient key that tracks the current batch offset. */
	const OFFSET_TRANSIENT = 'livada_seo_import_offset';

	/** Transient key that tracks the total post count for the current run. */
	const TOTAL_TRANSIENT = 'livada_seo_import_total';

	/** Number of posts processed per batch. */
	const BATCH_SIZE = 50;

	/**
	 * Maps SEOPress meta keys to their Livada SEO equivalents.
	 * Keys whose values are URLs must also appear in URL_FIELDS.
	 *
	 * @var array<string,string>
	 */
	const 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',
	];

	/**
	 * SEOPress meta keys whose values must be treated as URLs.
	 * All other keys in META_MAP are treated as plain text.
	 *
	 * @var string[]
	 */
	const URL_FIELDS = [
		'_seopress_robots_canonical',
		'_seopress_social_fb_img',
	];

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

	/**
	 * Registers AJAX hooks.
	 * Called once by Livada_SEO_Loader::init().
	 */
	public static function init(): void {
		add_action( 'wp_ajax_livada_seo_import_seopress', [ self::class, 'handle_ajax' ] );
	}

	// ── AJAX entry point ──────────────────────────────────────────────────────

	/**
	 * Handles the `wp_ajax_livada_seo_import_seopress` AJAX action.
	 *
	 * Verifies nonce and capability, then dispatches to the appropriate handler
	 * based on the `action_type` POST parameter.
	 */
	public static function handle_ajax(): void {
		// Security: nonce check. Uses the shared admin nonce that admin.js localises.
		check_ajax_referer( 'livada_seo_admin_nonce', 'nonce' );

		// Security: capability check.
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'livada-seo' ) ], 403 );
		}

		$action_type = isset( $_POST['action_type'] ) ? sanitize_key( $_POST['action_type'] ) : '';

		switch ( $action_type ) {
			case 'start':
				wp_send_json_success( self::handle_start() );
				break;

			case 'batch':
				wp_send_json_success( self::handle_batch() );
				break;

			case 'redirections':
				wp_send_json_success( self::handle_redirections() );
				break;

			default:
				wp_send_json_error( [ 'message' => __( 'Unknown action_type.', 'livada-seo' ) ], 400 );
		}
	}

	// ── Action handlers ───────────────────────────────────────────────────────

	/**
	 * Resets import state and returns the total number of posts to process.
	 *
	 * @return array{done: bool, processed: int, total: int, offset: int, warnings: array}
	 */
	private static function handle_start(): array {
		// Reset the offset to zero so a fresh run starts from the beginning.
		set_transient( self::OFFSET_TRANSIENT, 0, HOUR_IN_SECONDS );

		$total = self::count_importable_posts();
		set_transient( self::TOTAL_TRANSIENT, $total, HOUR_IN_SECONDS );

		return [
			'done'      => false,
			'processed' => 0,
			'total'     => $total,
			'offset'    => 0,
			'warnings'  => [],
		];
	}

	/**
	 * Processes the next batch of posts and advances the offset transient.
	 *
	 * @return array{done: bool, processed: int, total: int, offset: int, warnings: array}
	 */
	private static function handle_batch(): array {
		$offset   = (int) get_transient( self::OFFSET_TRANSIENT );
		$total    = (int) get_transient( self::TOTAL_TRANSIENT );
		$warnings = [];

		// If transients are missing (e.g. expired), re-count to recover gracefully.
		if ( 0 === $total ) {
			$total = self::count_importable_posts();
			set_transient( self::TOTAL_TRANSIENT, $total, HOUR_IN_SECONDS );
		}

		$posts = self::get_posts_batch( $offset );

		foreach ( $posts as $post ) {
			$post_warnings = self::migrate_post_meta( $post->ID );
			foreach ( $post_warnings as $warning ) {
				$warnings[] = sprintf(
					/* translators: 1: post ID, 2: warning detail */
					__( 'Post %1$d: %2$s', 'livada-seo' ),
					$post->ID,
					$warning
				);
			}
		}

		$batch_count  = count( $posts );
		$new_offset   = $offset + $batch_count;
		$processed    = $new_offset;

		// Persist the new offset for the next request.
		set_transient( self::OFFSET_TRANSIENT, $new_offset, HOUR_IN_SECONDS );

		$done = ( $batch_count < self::BATCH_SIZE ) || ( $new_offset >= $total );

		if ( $done ) {
			// Clean up transients once the run is complete.
			delete_transient( self::OFFSET_TRANSIENT );
			delete_transient( self::TOTAL_TRANSIENT );
		}

		return [
			'done'      => $done,
			'processed' => $processed,
			'total'     => $total,
			'offset'    => $new_offset,
			'warnings'  => $warnings,
		];
	}

	/**
	 * Imports all SEOPress redirections stored in the `seopress_404` CPT.
	 *
	 * @return array{done: bool, imported: int, skipped: int, warnings: array}
	 */
	private static function handle_redirections(): array {
		$warnings = [];
		$imported = 0;
		$skipped  = 0;

		// Require the redirects class if not already loaded (safety net).
		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			$redirects_file = LIVADA_SEO_PATH . 'includes/class-redirects.php';
			if ( file_exists( $redirects_file ) ) {
				require_once $redirects_file;
			}
		}

		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			return [
				'done'     => true,
				'imported' => 0,
				'skipped'  => 0,
				'warnings' => [ __( 'Redirects module unavailable.', 'livada-seo' ) ],
			];
		}

		// Query all SEOPress 404/redirection CPT posts.
		$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;

			// Source URL is the post slug/name for seopress_404 CPT.
			$post = get_post( $post_id );
			if ( ! $post ) {
				continue;
			}

			// SEOPress stores the original 404 path as the post name.
			$source = '/' . ltrim( $post->post_name, '/' );

			// Target URL.
			$raw_target = get_post_meta( $post_id, '_seopress_redirections_value', true );

			// Redirect type (default 301 if not set).
			$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(
					/* translators: post ID */
					__( 'Redirection post %d: missing target URL, skipped.', 'livada-seo' ),
					$post_id
				);
				continue;
			}

			$target = esc_url_raw( $raw_target );

			if ( empty( $target ) ) {
				$skipped++;
				$warnings[] = sprintf(
					/* translators: post ID */
					__( 'Redirection post %d: invalid target URL, skipped.', 'livada-seo' ),
					$post_id
				);
				continue;
			}

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

			if ( false === $result ) {
				// Likely a duplicate source — not a hard error.
				$skipped++;
				$warnings[] = sprintf(
					/* translators: 1: post ID, 2: source URL */
					__( 'Redirection post %1$d: could not insert source "%2$s" (may already exist).', 'livada-seo' ),
					$post_id,
					$source
				);
			} else {
				$imported++;
			}
		}

		return [
			'done'     => true,
			'imported' => $imported,
			'skipped'  => $skipped,
			'warnings' => $warnings,
		];
	}

	// ── Post meta migration ───────────────────────────────────────────────────

	/**
	 * Copies SEOPress meta values to Livada SEO meta keys for a single post.
	 *
	 * Only fields that have a non-empty SEOPress value are written; existing
	 * Livada SEO values are overwritten so a re-run stays idempotent.
	 *
	 * @param  int      $post_id WordPress post ID.
	 * @return string[] Array of warning strings; empty when all values migrated cleanly.
	 */
	private static function migrate_post_meta( int $post_id ): array {
		$warnings = [];

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

			// Skip entirely absent or empty values — don't overwrite with blank.
			if ( '' === $raw || false === $raw ) {
				continue;
			}

			$is_url   = in_array( $seopress_key, self::URL_FIELDS, true );
			$sanitized = self::sanitize_value( (string) $raw, $is_url );

			if ( null === $sanitized ) {
				// Sanitization returned an invalid result; record warning and skip.
				$field_label = str_replace( '_seopress_', '', $seopress_key );
				$warnings[]  = sprintf(
					/* translators: field label */
					__( 'invalid %s skipped', 'livada-seo' ),
					$field_label
				);
				continue;
			}

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

		return $warnings;
	}

	// ── Sanitization ──────────────────────────────────────────────────────────

	/**
	 * Sanitizes a single meta value.
	 *
	 * @param  string $value  Raw value from SEOPress post meta.
	 * @param  bool   $is_url Whether to apply URL sanitization instead of text.
	 * @return string|null    Sanitized string, or null when the value is invalid.
	 */
	private static function sanitize_value( string $value, bool $is_url ): ?string {
		if ( $is_url ) {
			$sanitized = esc_url_raw( $value );

			// esc_url_raw() returns an empty string for values it considers invalid.
			if ( '' === $sanitized ) {
				return null;
			}

			return $sanitized;
		}

		$sanitized = sanitize_text_field( $value );

		// sanitize_text_field() strips all tags; if the result is empty but the
		// original was not, the value contained only markup — treat as invalid.
		if ( '' === $sanitized && '' !== $value ) {
			return null;
		}

		return $sanitized;
	}

	// ── Query helpers ─────────────────────────────────────────────────────────

	/**
	 * Counts all public posts that have at least one SEOPress meta key set.
	 *
	 * Uses a direct SQL EXISTS sub-query for efficiency on large sites.
	 *
	 * @return int Total number of posts to import.
	 */
	private static function count_importable_posts(): int {
		global $wpdb;

		$keys_placeholder = implode( ', ', array_fill( 0, count( self::META_MAP ), '%s' ) );
		$keys             = array_keys( self::META_MAP );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$count = (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 ({$keys_placeholder})
				   )",
				...$keys
			)
		);

		return $count;
	}

	/**
	 * Retrieves a batch of post IDs (wrapped in objects) starting at the given offset.
	 *
	 * Returns only posts that have at least one SEOPress meta key, matching the
	 * same criteria used in count_importable_posts().
	 *
	 * @param  int      $offset Zero-based starting offset.
	 * @return object[]         Rows with an `ID` property.
	 */
	private static function get_posts_batch( int $offset ): array {
		global $wpdb;

		$keys_placeholder = implode( ', ', array_fill( 0, count( self::META_MAP ), '%s' ) );
		$keys             = array_keys( self::META_MAP );

		// 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 ({$keys_placeholder})
				   )
				 ORDER BY p.ID ASC
				 LIMIT %d OFFSET %d",
				...array_merge( $keys, [ self::BATCH_SIZE, $offset ] )
			)
		);

		return $rows ?: [];
	}
}
