<?php
/**
 * DistributorHelper
 *
 * Helper class for Distributor customizations.
 *
 * @package CommonFrameworkPlugin
 */

namespace CommonFrameworkPlugin;

use Distributor\Connections;
use Distributor\Authentications\WordPressBasicAuth;
use Elementor\Plugin;
use Elementor\Core\Files\CSS\Post as Post_CSS;

use WP_Query;

/**
 * Helper class for Distributor customizations.
 *
 * Responsibilities: build and adapt payloads for Distributor transports; sync ACF post and term data;
 * resolve related posts and media across sites; clear caches after content updates; assist with Elementor data.
 */
class DistributorHelper {

	/**
	 * Set default configuration values for the plugin and store them in the dt_settings option.
	 *
	 * @return void
	 */
	public static function setup_defaults() {
		$defaults = [
			'override_author_byline' => true,
			'media_handling' => 'attached',
			'email' => 'developers@strategynewmedia.com',
			'license_key' => '46076a5cac9f14b0c8addcd31de79389',
			'valid_license' => true,
		];
		update_option( 'dt_settings', $defaults );
	}

	/**
	 * Decide Distributor transport so date keys can be normalized.
	 * External or subscription uses REST keys: date and date_gmt.
	 * Internal uses wp_insert_post keys: post_date and post_date_gmt.
	 *
	 * @param mixed $connection Distributor connection instance used to infer transport type.
	 * @return string Transport type: 'external' or 'internal'.
	 */
	public static function get_transport_for_connection( $connection ): string {
		return ( $connection instanceof \Distributor\ExternalConnections\WordPressExternalConnection )
			? 'external'
			: 'internal';
	}

	/**
	 * Build a canonical payload from a post: dates; parent hint; ACF post and term fields; ACF relations.
	 * Used for initial push and for subscription updates.
	 *
	 * @param \WP_Post $post WordPress post object to read from.
	 * @return array Canonical payload for the given post.
	 */
	public static function build_common_payload( \WP_Post $post ): array {
		$payload = [
			'post_date'     => $post->post_date,
			'post_date_gmt' => $post->post_date_gmt,
		];

		// Parent hint, safe to map on receiver without knowing remote IDs
		if ( is_post_type_hierarchical( $post->post_type ) && $post->post_parent ) {
			$parent = get_post( $post->post_parent );
			if ( $parent ) {
				$path = function_exists( 'get_page_uri' ) ? get_page_uri( $parent->ID ) : $parent->name;
				$payload['parent_ref'] = [
					'source_site' => home_url(),
					'source_id'   => $parent->ID,
					'post_type'   => $parent->post_type,
					'slug'        => $parent->name,
					'path'        => $path,
					'url'         => get_permalink( $parent->ID ),
				];
			}
		}

		// ACF Post Objects & Relationship fields
		$payload['acf_relations'] = self::build_relation_refs_payload( $post );

		// ACF post level
		if ( function_exists( 'get_field_objects' ) ) {
			// Post level
			$objs = get_field_objects( $post->ID );
			if ( $objs ) {
				foreach ( $objs as $obj ) {
					if ( array_key_exists( 'key', $obj ) && array_key_exists( 'value', $obj ) ) {
						$payload['acf_post_fields_k'][ $obj['key'] ] = $obj['value'];
					}
					// Backward compatible: by name
					if ( array_key_exists( 'name', $obj ) && array_key_exists( 'value', $obj ) ) {
						$payload['acf_post_fields'][ $obj['name'] ] = $obj['value'];
					}
				}
			}

			// Term level
			$payload['term_props'] = [];
			$taxonomies = get_object_taxonomies( $post->post_type );
			foreach ( $taxonomies as $taxonomy ) {
				$terms = wp_get_post_terms( $post->ID, $taxonomy );
				foreach ( $terms as $term ) {
					$tobjs = get_field_objects( $term );
					if ( $tobjs ) {
						foreach ( $tobjs as $obj ) {
							if ( array_key_exists( 'key', $obj ) && array_key_exists( 'value', $obj ) ) {
								$payload['acf_term_fields_k'][ $taxonomy ][ $term->slug ][ $obj['key'] ] = $obj['value'];
							}
							if ( array_key_exists( 'name', $obj ) && array_key_exists( 'value', $obj ) ) {
								$payload['acf_term_fields'][ $taxonomy ][ $term->slug ][ $obj['name'] ] = $obj['value'];
							}
						}
					}
					$payload['term_props'][ $taxonomy ][ $term->slug ] = [
						'name'        => $term->name,
						'description' => $term->description,
						// 'slug'        => $term->slug,
						'source_id'   => (int) $term->term_id,
						'source_site' => home_url(),
					];
				}
			}
		} elseif ( function_exists( 'get_fields' ) ) {
			$acf = get_fields( $post->ID );
			if ( $acf ) {
				$payload['acf_post_fields'] = $acf;
			}
			$taxonomies = get_object_taxonomies( $post->post_type );
			foreach ( $taxonomies as $taxonomy ) {
				$terms = wp_get_post_terms( $post->ID, $taxonomy );
				foreach ( $terms as $term ) {
					$tacf = get_fields( $term );
					if ( $tacf ) {
						$payload['acf_term_fields'][ $taxonomy ][ $term->slug ] = $tacf;
					}
				}
			}
		}

		return $payload;
	}

	/**
	 * Adapt a canonical payload to the transport Distributor will use.
	 * External and subscription use REST keys; internal uses wp_insert_post keys.
	 *
	 * @param array  $payload Canonical payload to adapt.
	 * @param string $transport Transport type: 'external', 'subscription', or 'internal'.
	 * @return array Adapted payload for the specified transport.
	 */
	public static function adapt_for_transport( array $payload, string $transport ): array {
		$out = $payload;

		if ( 'external' === $transport || 'subscription' === $transport ) {
			if ( isset( $out['post_date'] ) ) {
				$out['date'] = $out['post_date'];
				unset( $out['post_date'] );
			}
			if ( isset( $out['post_date_gmt'] ) ) {
				$out['date_gmt'] = $out['post_date_gmt'];
				unset( $out['post_date_gmt'] );
			}
		}

		return $out;
	}

	/**
	 * Apply incoming payload to a local post: ACF fields; relations; dates; parent.
	 * Accepts canonical date keys and REST date keys.
	 *
	 * @param int   $post_id Local post ID to update.
	 * @param array $payload Incoming payload data.
	 * @return void
	 */
	public static function apply_incoming_payload( int $post_id, array $payload ) {
		$need_update = [];

		// Post Parent
		if ( isset( $payload['parent_ref'] ) && ! empty( $payload['parent_ref'] ) ) {
			$parent_id = self::resolve_or_pull_related_post( $payload['parent_ref'] );
			if ( $parent_id && is_post_type_hierarchical( get_post_type( $post_id ) ) ) {
				$need_update['post_parent'] = $parent_id;
			}
		}

		// Term updates
		if ( ! empty( $payload['term_props'] ) && is_array( $payload['term_props'] ) ) {
			foreach ( $payload['term_props'] as $taxonomy => $by_slug ) {
				foreach ( (array) $by_slug as $slug => $info ) {
					self::update_term_name_description( $taxonomy, $slug, (array) $info );
				}
			}
		}

		// 1) ACF post and term fields
		if ( function_exists( 'update_field' ) ) {
			// Post level
			if ( ! empty( $payload['acf_post_fields_k'] ) ) {
				foreach ( (array) $payload['acf_post_fields_k'] as $field_key => $value ) {
					update_field( $field_key, $value, $post_id );
				}
			} elseif ( ! empty( $payload['acf_post_fields'] ) ) {
				foreach ( (array) $payload['acf_post_fields'] as $name => $value ) {
					update_field( $name, $value, $post_id );
				}
			}

			// Term level
			if ( ! empty( $payload['acf_term_fields_k'] ) ) {
				foreach ( $payload['acf_term_fields_k'] as $taxonomy => $by_slug ) {
					foreach ( $by_slug as $slug => $fields_kv ) {
						$term = get_term_by( 'slug', $slug, $taxonomy );
						if ( $term && ! is_wp_error( $term ) ) {
							foreach ( (array) $fields_kv as $field_key => $value ) {
								update_field( $field_key, $value, 'term_' . $term->term_id );
							}
							clean_term_cache( [ $term->term_id ], $taxonomy );
						}
					}
				}
			} elseif ( ! empty( $payload['acf_term_fields'] ) ) {
				foreach ( $payload['acf_term_fields'] as $taxonomy => $by_slug ) {
					foreach ( $by_slug as $slug => $fields ) {
						$term = get_term_by( 'slug', $slug, $taxonomy );
						if ( $term && ! is_wp_error( $term ) ) {
							foreach ( (array) $fields as $name => $value ) {
								update_field( $name, $value, 'term_' . $term->term_id );
							}
							clean_term_cache( [ $term->term_id ], $taxonomy );
						}
					}
				}
			}

			// Ensure related posts exist locally, then set the ACF post_object or relationship values
			if ( ! empty( $payload['acf_relations'] ) && is_array( $payload['acf_relations'] ) ) {
				self::ensure_relations_and_set_fields( $post_id, $payload['acf_relations'] );
			}
		}

		// 2) Dates
		$date = $payload['post_date'] ?? $payload['date'] ?? null;
		$date_gmt = $payload['post_date_gmt'] ?? $payload['date_gmt'] ?? null;
		if ( $date ) {
			$need_update['post_date'] = $date;
		}
		if ( $date_gmt ) {
			$need_update['post_date_gmt'] = $date_gmt;
		}

		if ( $need_update ) {
			$need_update['ID'] = $post_id;
			wp_update_post( $need_update );
		}
	}

	/**
	 * Collect references for ACF post_object and relationship fields on a post.
	 *
	 * @param \WP_Post $post WordPress post object to inspect.
	 * @return array Map keyed by ACF field name with relation definitions and items.
	 */
	protected static function build_relation_refs_payload( \WP_Post $post ) {
		$out = [];
		if ( ! function_exists( 'get_field_objects' ) ) {
			return $out;
		}
		$objs = get_field_objects( $post->ID );
		if ( ! is_array( $objs ) ) {
			return $out;
		}
		foreach ( $objs as $obj ) {
			$type = $obj['type'] ?? '';
			if ( ! in_array( $type, [ 'post_object', 'relationship' ], true ) ) {
				continue;
			}
			$name  = $obj['name'] ?? null;
			$value = $obj['value'] ?? null;
			if ( ! $name || empty( $value ) ) {
				continue;
			}
			$ids = [];
			if ( is_array( $value ) ) {
				foreach ( $value as $v ) { $ids[] = $v instanceof \WP_Post ? $v->ID : absint( $v ); }
			} else {
				$ids[] = $value instanceof \WP_Post ? $value->ID : absint( $value );
			}
			$ids = array_filter( array_unique( $ids ) );
			if ( ! $ids ) { continue; }

			$items = [];
			foreach ( $ids as $rid ) {
				$pt   = get_post_type( $rid );
				$slug = get_post_field( 'post_name', $rid );
				if ( ! $pt || ! $slug ) { continue; }
				$path = function_exists( 'get_page_uri' ) ? get_page_uri( $rid ) : $slug; // full hierarchical path when available
				$items[] = [
					'source_site' => home_url(),
					'source_id'   => (int) $rid,
					'post_type'   => $pt,
					'slug'        => $slug,
					'path'        => $path,
					'url'         => get_permalink( $rid ),
				];
			}
			if ( $items ) {
				$out[ $name ] = [
					'name'  => $name,
					'mode'  => is_array( $value ) ? 'multiple' : 'single',
					'items' => $items,
				];
			}
		}
		return $out;
	}

	/**
	 * Ensure referenced posts exist locally; set ACF field values to local IDs.
	 *
	 * @param int   $post_id Receiving post ID.
	 * @param array $relations Relation definitions created by build_relation_refs_payload.
	 * @return void
	 */
	protected static function ensure_relations_and_set_fields( int $post_id, array $relations ) {
		foreach ( $relations as $field_name => $def ) {
			$items = $def['items'] ?? [];
			if ( ! $items ) {
				continue;
			}

			$local_ids = [];
			foreach ( $items as $ref ) {
				$lid = self::resolve_or_pull_related_post( $ref );
				if ( $lid ) { $local_ids[] = $lid; }
			}
			if ( ! $local_ids ) {
				continue;
			}

			$mode = $def['mode'] ?? 'single';
			$value = ( 'single' === $mode ) ? (int) $local_ids[0] : array_map( 'intval', $local_ids );

			// Update by local field key if available, else by name
			if ( function_exists( 'get_field_object' ) && function_exists( 'update_field' ) ) {
				$obj = get_field_object( $field_name, $post_id, false, false );
				$target = ( is_array( $obj ) && ! empty( $obj['key'] ) ) ? $obj['key'] : $field_name;
				update_field( $target, $value, $post_id );
			}
		}
	}

	/**
	 * Update a term name and description without changing the slug.
	 * Matches by stored mapping metadata first; falls back to current slug.
	 *
	 * @param string $taxonomy Taxonomy name.
	 * @param string $slug Term slug to update or create.
	 * @param array  $info Term info: name; description; optional source_id; optional source_site.
	 * @return void
	 */
	protected static function update_term_name_description( string $taxonomy, string $slug, array $info ) {
		$want_name = isset( $info['name'] ) ? (string) $info['name'] : '';
		$want_desc = isset( $info['description'] ) ? (string) $info['description'] : '';
		$source_id = isset( $info['source_id'] ) ? (int) $info['source_id'] : 0;
		$source_site = isset( $info['source_site'] ) ? (string) $info['source_site'] : '';

		$term_id = 0;

		// 1) Try mapping meta for stable identity
		if ( $source_id && $source_site ) {
			$found = get_terms(
				[
					'taxonomy'   => $taxonomy,
					'hide_empty' => false,
					'fields'     => 'ids',
					'meta_query' => [
						[
							'key' => '_cf_dt_source_id',
							'value' => $source_id,
						],
						[
							'key' => '_cf_dt_source_site',
							'value' => $source_site,
						],
					],
				]
			);
			if ( ! is_wp_error( $found ) && ! empty( $found ) ) {
				$term_id = (int) $found[0];
			}
		}

		// 2) Fall back to slug
		if ( ! $term_id ) {
			$t = get_term_by( 'slug', sanitize_title( $slug ), $taxonomy );
			if ( $t && ! is_wp_error( $t ) ) {
				$term_id = (int) $t->term_id;
			}
		}

		// 3) If still missing, create it, then update props
		if ( ! $term_id ) {
			$ins = wp_insert_term(
				$want_name ? $want_name : $slug,
				$taxonomy,
				[
					'slug'        => $slug,
					'description' => $want_desc,
				]
			);
			if ( ! is_wp_error( $ins ) ) {
				$term_id = (int) $ins['term_id'];
			}
		}

		if ( ! $term_id ) {
			return;
		}

		// 4) Write mapping meta for future updates
		if ( $source_id ) {
			update_term_meta( $term_id, '_cf_dt_source_id', $source_id );
		}
		if ( $source_site ) {
			update_term_meta( $term_id, '_cf_dt_source_site', $source_site );
		}

		// 5) Update name and description only
		$params = [];
		if ( '' !== $want_name ) {
			$params['name'] = $want_name;
		}
		$params['description'] = $want_desc;

		if ( $params ) {
			wp_update_term( $term_id, $taxonomy, $params );
		}

		clean_term_cache( [ $term_id ], $taxonomy );
	}

	/**
	 * Resolve or fetch a related post from a reference.
	 * Attempts strict Distributor mappings; URL mapping; tolerant site ID mapping; hierarchical path; slug.
	 * Falls back to external pull if not found locally.
	 *
	 * @param array $ref Relation reference: source_id; source_site; post_type; slug; path; url.
	 * @return int Local post ID or 0 if not resolved.
	 */
	protected static function resolve_or_pull_related_post( array $ref ): int {
		$source_id   = isset( $ref['source_id'] ) ? (int) $ref['source_id'] : 0;
		$source_site = isset( $ref['source_site'] ) ? (string) $ref['source_site'] : '';
		$post_type   = isset( $ref['post_type'] ) ? (string) $ref['post_type'] : '';
		$slug        = isset( $ref['slug'] ) ? (string) $ref['slug'] : '';
		$path        = isset( $ref['path'] ) ? (string) $ref['path'] : '';
		$url         = isset( $ref['url'] ) ? (string) $ref['url'] : '';

		// 1) Local by Distributor mapping meta,
		if ( $source_id && $source_site ) {
			$found = get_posts(
				[
					'post_type'      => $post_type,
					'post_status'    => 'any',
					'posts_per_page' => 1,
					'fields'         => 'ids',
					'meta_query'     => [
						'relation' => 'AND',
						[
							'key'     => 'dt_original_post_id',
							'value'   => $source_id,
							'compare' => '=',
							'type'    => 'NUMERIC',
						],
						[
							'key'     => 'dt_original_site_url',
							'value'   => $source_site,
							'compare' => '=',
						],
					],
				]
			);
			if ( $found ) {
				return (int) $found[0];
			}
		}

		// 2) Still strict: match original post url exactly
		if ( $url ) {
			$found = get_posts(
				[
					'post_type'      => $post_type,
					'post_status'    => 'any',
					'posts_per_page' => 1,
					'fields'         => 'ids',
					'meta_query'     => [
						[
							'key'     => 'dt_original_post_url',
							'value'   => $url,
							'compare' => '=',
						],
					],
				]
			);
			if ( $found ) {
				return (int) $found[0];
			}
		}

		// 3) Tolerant mapping: id plus a looser site identifier, only if we could not match above
		if ( $source_id && $source_site ) {
			$found = get_posts(
				[
					'post_type'      => $post_type,
					'post_status'    => 'any',
					'posts_per_page' => 1,
					'fields'         => 'ids',
					'meta_query'     => [
						'relation' => 'AND',
						[
							'key'     => 'dt_original_post_id',
							'value'   => $source_id,
							'compare' => '=',
							'type'    => 'NUMERIC',
						],
						[
							'key'     => 'dt_original_site_id', // often serialized, use LIKE
							'value'   => $source_site,
							'compare' => 'LIKE',
						],
					],
				]
			);
			if ( $found ) {
				return (int) $found[0];
			}
		}

		// 4) Hierarchical path, exact type
		if ( $path && is_post_type_hierarchical( $post_type ) ) {
			$maybe = get_page_by_path( $path, OBJECT, $post_type );
			if ( $maybe instanceof \WP_Post ) {
				return (int) $maybe->ID;
			}
		}

		// 5) Slug fallback for non hierarchical types, use correct arg 'name'
		if ( $slug ) {
			$maybe = get_page_by_path( $slug, OBJECT, $post_type );
			if ( $maybe instanceof \WP_Post ) {
				return (int) $maybe->ID;
			}
			$maybe_ids = get_posts(
				[
					'post_type'      => $post_type,
					'post_status'    => 'any',
					'name'           => $slug,
					'posts_per_page' => 1,
					'fields'         => 'ids',
				]
			);
			if ( $maybe_ids ) {
				return (int) $maybe_ids[0];
			}
		}

		// 6) Pull from external if nothing local matched
		$lid = self::try_pull_via_external_connection( $source_site, $source_id, $post_type );
		if ( $lid ) {
			return $lid;
		}

		return 0;
	}

	/**
	 * Try to pull a post via an external WordPress connection that matches the source site.
	 *
	 * @param string $source_site Source site URL.
	 * @param int    $remote_id Remote post ID at the source.
	 * @param string $post_type Post type slug.
	 * @return int Local post ID on success or 0 on failure.
	 */
	protected static function try_pull_via_external_connection( string $source_site, int $remote_id, string $post_type ): int {
		if ( ! $source_site || ! $remote_id || ! $post_type ) { return 0; }

		$external_wp_connection_name = self::get_external_wp_connection_name();
		if ( ! $external_wp_connection_name ) { return 0; }

		$q = new WP_Query(
			[
				'post_type' => 'dt_ext_connection',
				'posts_per_page' => -1,
			]
		);

		foreach ( (array) $q->posts as $conn_post ) {
			$base_url = rtrim( get_post_meta( $conn_post->ID, 'dt_external_connection_url', true ), '/' );
			if ( ! $base_url ) { continue; }

			$h1 = wp_parse_url( $base_url, PHP_URL_HOST );
			$h2 = wp_parse_url( $source_site, PHP_URL_HOST );

			if ( ! $h1 || ! $h2 || strcasecmp( $h1, $h2 ) !== 0 ) { continue; }

			$auth_credentials = get_post_meta( $conn_post->ID, 'dt_external_connection_auth', true );
			$auth       = new WordPressBasicAuth( $auth_credentials );
			$connection = new $external_wp_connection_name( $conn_post->post_title, $base_url, $conn_post->ID, $auth );

			if ( ! method_exists( $connection, 'remote_get' ) || ! method_exists( $connection, 'pull' ) ) {
				continue;
			}

			$remote = $connection->remote_get(
				[
					'id' => $remote_id,
					'post_type' => $post_type,
				]
			);
			if ( is_wp_error( $remote ) || empty( $remote ) ) {
				continue;
			}

			$items[] = [
				'remote_post_id' => $remote instanceof \WP_Post ? (int) $remote->ID : (int) ( $remote['id'] ?? $remote_id ),
				'post_type'      => $post_type,
			];
			$res = $connection->pull( $items );

			if ( ! is_wp_error( $res ) && is_numeric( $res ) && (int) $res > 0 ) {
				return (int) $res;
			}
		}

		return 0;
	}

	/**
	 * Ask the remote for the post type object to get rest_base; fall back to the type slug.
	 *
	 * @param object $connection External connection instance that provides remote_get.
	 * @param string $post_type Post type slug.
	 * @return string REST base for the type or the original post type.
	 */
	protected static function remote_rest_base( $connection, string $post_type ): string {
		$types = $connection->remote_get( sprintf( 'wp/v2/types/%s', $post_type ) );
		if ( is_wp_error( $types ) || empty( $types ) ) {
			return $post_type;
		}
		if ( is_string( $types ) ) {
			$types = json_decode( $types, true );
		}
		$rest_base = is_array( $types ) && ! empty( $types['rest_base'] ) ? $types['rest_base'] : '';
		return $rest_base ? $rest_base : $post_type;
	}

	/**
	 * Get registered external Distributor connections from the factory.
	 *
	 * @return array|bool Array of registered connections keyed by slug or false if unavailable.
	 */
	protected static function get_registered_connections() {
		if ( ! class_exists( '\Distributor\Connections' ) ) {
			return false;
		}
		$factory = Connections::factory();
		if ( ! $factory || ! method_exists( $factory, 'get_registered' ) ) {
			return false;
		}
		return $factory->get_registered( 'external' );
	}

	/**
	 * Get the registered connection name for the 'networkblog' key.
	 *
	 * @return string|bool Connection class name or false if not registered.
	 */
	public static function get_network_connection_name() {
		$connections = self::get_registered_connections();
		return ! empty( $connections['networkblog'] ) ? $connections['networkblog'] : false;
	}

	/**
	 * Get the registered connection name for the 'wp' key.
	 *
	 * @return string|bool Connection class name or false if not registered.
	 */
	public static function get_external_wp_connection_name() {
		$connections = self::get_registered_connections();
		return ! empty( $connections['wp'] ) ? $connections['wp'] : false;
	}

	/**
	 * Clear caches after content update for a given post.
	 * Triggers Elementor and Rocket cache clears when available.
	 *
	 * @param int $post_id Post ID to clear caches for.
	 * @return void
	 */
	public static function after_content_update_caches( int $post_id ) {
		self::maybe_clear_elementor_cache( $post_id );
		self::maybe_clear_rocket_cache( $post_id );
	}

	/**
	 * Maybe clear WP Rocket cache for a post.
	 *
	 * @param int $post_id Post ID to clear.
	 * @return void
	 */
	protected static function maybe_clear_rocket_cache( $post_id ) {
		if ( function_exists( 'rocket_clean_post' ) ) {
			rocket_clean_post( $post_id );
		}
	}

	/**
	 * Maybe clear Elementor caches for a post.
	 * Regenerates per post CSS when possible; otherwise clears Elementor cached CSS.
	 *
	 * @param int $post_id Post ID to clear.
	 * @return void
	 */
	protected static function maybe_clear_elementor_cache( $post_id ) {
		if ( class_exists( '\Elementor\Plugin' ) ) {
			$doc = Plugin::instance()->documents->get( $post_id );
			if ( $doc ) {
				// Save to ensure the document data is in sync
				$doc->save( [ 'force' => true ] );
			}

			// Regenerate CSS for this specific post
			if ( class_exists( '\Elementor\Core\Files\CSS\Post' ) ) {
				$css = Post_CSS::create( $post_id );
				if ( $css ) {
					$css->update();
				}
				Plugin::instance()->files_manager->clear_cache();
			}
		}
	}

	/**
	 * Walk Elementor JSON stored in _elementor_data; resolve media objects to local attachments; update IDs and URLs; save.
	 *
	 * @param int $post_id Post ID whose Elementor data should be localized.
	 * @return void
	 */
	public static function localize_elementor_media( int $post_id ) {
		$document = Plugin::instance()->documents->get( $post_id );

		if ( ! $document ) {
			return;
		}

		$data = $document->get_elements_data();

		$source_site = get_post_meta( $post_id, 'dt_original_site_url', true );
		$changed = false;

		$resolver = function ( array $media ) use ( $post_id, $source_site, &$changed ): array {
			$remote_id = isset( $media['id'] ) ? (int) $media['id'] : 0;
			$remote_url = isset( $media['url'] ) ? (string) $media['url'] : '';

			$local_id = self::resolve_or_sideload_media( $post_id, $remote_id, $remote_url, $source_site );
			if ( $local_id ) {
				$media['id']  = $local_id;
				$media['url'] = wp_get_attachment_url( $local_id ) ? wp_get_attachment_url( $local_id ) : $media['url'];
				$changed = true;
			}
			return $media;
		};

		$data = self::walk_elementor_media( $data, $resolver );

		if ( $changed ) {
			$document->save( $data );
		}
	}

	/**
	 * Recursive walker for Elementor saved data.
	 * Replaces any setting that looks like a media object; also handles galleries.
	 *
	 * @param mixed    $node Any node from Elementor data: array or scalar.
	 * @param callable $resolver Function that receives a media array and returns a media array.
	 * @return mixed Node with media entries rewritten as needed.
	 */
	protected static function walk_elementor_media( $node, callable $resolver ) {
		if ( is_array( $node ) ) {
			// Detect a single media object, common shape: ['id' => int, 'url' => string]
			$is_media_object = isset( $node['id'] ) && ( is_int( $node['id'] ) || ctype_digit( (string) $node['id'] ) )
				&& isset( $node['url'] ) && is_string( $node['url'] );

			if ( $is_media_object ) {
				return $resolver( $node );
			}

			// Handle gallery like shapes: ['ids' => [1,2,...]] or ['gallery' => [ ['id'=>..,'url'=>..], ... ]]
			if ( isset( $node['ids'] ) && is_array( $node['ids'] ) ) {
				$node['ids'] = array_map(
					function ( $rid ) use ( $resolver ) {
						$media = [
							'id' => (int) $rid,
							'url' => '',
						];
						$media = $resolver( $media );
						return (int) $media['id'];
					},
					$node['ids']
				);
			}
			if ( isset( $node['gallery'] ) && is_array( $node['gallery'] ) ) {
				foreach ( $node['gallery'] as $i => $gitem ) {
					if ( is_array( $gitem ) ) {
						$node['gallery'][ $i ] = self::walk_elementor_media( $gitem, $resolver );
					}
				}
			}

			// Recurse through settings and children
			foreach ( $node as $k => $v ) {
				$node[ $k ] = self::walk_elementor_media( $v, $resolver );
			}
		}
		return $node;
	}

	/**
	 * Resolve a remote attachment to a local one.
	 * Order: strict Distributor meta; uploads relative path; sideload as last resort.
	 *
	 * @param int    $post_id_context Receiving post ID used to attach sideloaded media.
	 * @param int    $remote_id Remote attachment ID from the source site.
	 * @param string $remote_url Source URL for the media file.
	 * @param string $source_site Source site URL used for mapping.
	 * @return int Local attachment ID or 0 if not resolved.
	 */
	protected static function resolve_or_sideload_media( int $post_id_context, int $remote_id, string $remote_url, string $source_site ) {
		// 1) Strict mapping by Distributor meta
		if ( $remote_id && $source_site ) {
			$ids = get_posts(
				[
					'post_type'      => 'attachment',
					'post_status'    => 'any',
					'fields'         => 'ids',
					'posts_per_page' => 1,
					'meta_query'     => [
						'relation' => 'AND',
						[
							'key' => 'dt_original_post_id',
							'value' => $remote_id,
							'compare' => '=',
							'type' => 'NUMERIC',
						],
						[
							'key' => 'dt_original_site_url',
							'value' => $source_site,
							'compare' => '=',
						],
					],
				]
			);
			if ( $ids ) {
				return (int) $ids[0];
			}
		}

		// 2) Find by uploads path, example: 2025/08/file.png
		if ( $remote_url ) {
			$needle = self::extract_uploads_relative_path( $remote_url );
			if ( $needle ) {
				$ids = get_posts(
					[
						'post_type'      => 'attachment',
						'post_status'    => 'any',
						'fields'         => 'ids',
						'posts_per_page' => 1,
						'meta_query'     => [
							[
								'key' => '_wp_attached_file',
								'value' => $needle,
								'compare' => '=',
							],
						],
					]
				);
				if ( $ids ) {
					return (int) $ids[0];
				}
			}
		}

		// 3) Sideload as a last resort, then stamp mapping meta
		if ( $remote_url ) {
			self::ensure_media_functions();
			$local_id = media_sideload_image( $remote_url, $post_id_context, null, 'id' );
			if ( is_wp_error( $local_id ) ) {
				return 0;
			}
			if ( $remote_id ) {
				update_post_meta( $local_id, 'dt_original_post_id', (int) $remote_id );
			}
			if ( $source_site ) {
				update_post_meta( $local_id, 'dt_original_site_url', $source_site );
			}
			update_post_meta( $local_id, 'dt_original_post_url', $remote_url );
			return (int) $local_id;
		}

		return 0;
	}

	/**
	 * Extract a relative uploads path from a full URL.
	 * Example: 2025/08/file.png.
	 *
	 * @param string $url Full media URL.
	 * @return string Relative uploads path or empty string if not detected.
	 */
	protected static function extract_uploads_relative_path( string $url ) {
		$path = (string) wp_parse_url( $url, PHP_URL_PATH );
		if ( '' === $path ) {
			return '';
		}
		// Try to cut at /uploads/
		if ( preg_match( '#/uploads/([^?]+)$#i', $path, $m ) ) {
			return ltrim( $m[1], '/' );
		}
		// Fallback, try YYYY/MM/filename.ext
		if ( preg_match( '#(\d{4}/\d{2}/[^/?]+)$#', $path, $m ) ) {
			return $m[1];
		}
		return '';
	}

	/**
	 * Ensure core media functions are loaded for sideload operations.
	 *
	 * @return void
	 */
	protected static function ensure_media_functions() {
		if ( ! function_exists( 'media_sideload_image' ) ) {
			require_once ABSPATH . 'wp-admin/includes/media.php';
			require_once ABSPATH . 'wp-admin/includes/file.php';
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}
	}
}
