<?php
/**
 * DistributorCustomizations
 *
 * @package CommonFrameworkPlugin
 */

namespace CommonFrameworkPlugin;

use Distributor\ExternalConnection;
use WP_CLI;
use WP_Query;

/**
 * Customizes the behavior of the Distributor plugin by handling post meta whitelisting,
 * preserving parent-child relationships during syndication, managing cache and styling updates
 * post-syndication, and ensuring remote media is properly localized.
 */
class DistributorCustomizations extends \CommonFrameworkPlugin\Module {

	/**
	 * Distributor helper instance.
	 *
	 * @var DistributorHelper
	 */
	protected $helper;

	/**
	 * Constructor.
	 * Requires the Distributor helper class and initializes the helper property.
	 *
	 * @return void
	 */
	public function __construct() {
		require_once COMMON_FRAMEWORK_PLUGIN_INC . 'helpers/distributor.php';
		$this->helper = new DistributorHelper();
	}

	/**
	 * Only register if on an admin page and if the Fieldmanager plugin is active.
	 *
	 * @return bool True if the registration is allowed.
	 */
	public function can_register() {
		return true;
	}

	/**
	 * Register our hooks.
	 *
	 * @return void
	 */
	public function register() {
		add_action( 'admin_init', [ $this, 'setup_defaults' ], 99, 3 );

		// Initial push and pull, already correct for first distribution
		add_filter( 'dt_push_post_args', [ $this, 'handle_push_post' ], 10, 4 ); // Format for initial push on source
		add_filter( 'dt_post_to_pull', [ $this, 'format_post_for_pull' ], 10, 1 ); // Format for initial pull on source

		add_filter( 'dt_pull_post_args', [ $this, 'handle_pull_post' ], 10, 4 ); // Ingestion of pull on receiver
		add_action( 'dt_pull_post', [ $this, 'after_receive_any' ], 10, 3 ); // Ingestion of push/pull updates on receiver

		// Every later save that updates subscribers
		add_filter( 'dt_subscription_post_args', [ $this, 'subscription_payload' ], 10, 2 ); // Format for subscription push/pull
		add_action( 'dt_process_subscription_attributes', [ $this, 'on_subscription_receive' ], 100, 2 ); // Ingestion of subscription push/pull

		add_action( 'init', [ $this, 'hook_into_all_post_types' ] ); // Ingestion of initial push on receiver
		// Add header to remote_post during push so receiver knows what hook to ingest
		add_filter(
			'dt_auth_format_post_args',
			function ( $args, $context, $connection ) {
				$args['headers']['X-Distributor-Request'] = 'TRUE';
				return $args;
			},
			10,
			3
		);

		add_filter( 'post_row_actions', [ $this, 'filter_admin_row_actions' ], 100, 2 );
		add_filter( 'page_row_actions', [ $this, 'filter_admin_row_actions' ], 100, 2 );

		add_action(
			'elementor/editor/init',
			function () {
				$post_id = get_the_ID();
				if ( get_post_meta( $post_id, 'dt_syndicate_time', true ) ) {
					if ( is_admin() && ! defined( 'DOING_AJAX' ) ) {
						wp_die( 'This post is syndicated and cannot be edited with Elementor.' );
					}
				}
			},
			10
		);

		// TODO: Auto remove subscription and unlink post when deleted.
		// add_action( 'before_delete_post', [ $this, 'unlink_distributor_post_on_delete' ] );
		add_filter( 'dt_auto_distribution_enabled', '__return_true' );
		add_filter(
			'auto_distribute_supported_post_types',
			function ( $post_types ) {
				return [ 'products' ];
			}
		);

		add_filter(
			'dt_push_post_timeout',
			function ( $timeout, $post ) {
				return 90;
			},
			10,
			2
		);

		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			WP_CLI::add_command(
				'distributor distribute-products',
				function () {
					$this->distribute_all_products();
				}
			);
		}

		// Link existing posts on external connections if available
		add_action( 'wp_ajax_dt_push', [ $this, 'prelink_external_posts_by_slug' ], 0 );
		add_action( 'rest_api_init', [ $this, 'find_post_endpoint' ] );

		// Delete syndicated posts from external connections if available
		add_action( 'wp_trash_post', [ $this, 'on_trash_distributed_post' ], 10, 1 );
		add_action( 'before_delete_post', [ $this, 'on_delete_distributed_post' ], 10, 1 );

		add_action( 'init', function() {
			remove_filter( 'get_canonical_url', 'Distributor\\Hooks\\get_canonical_url', 10 );
			remove_filter( 'wpseo_canonical', 'Distributor\\Hooks\\wpseo_canonical', 10 );
		}, 20 );
	}

	/**
	 * Sets default configuration values for the plugin and stores them in the distributor_settings WordPress option.
	 *
	 * @return void
	 */
	public function setup_defaults() {
		$this->helper::setup_defaults();
	}

	/**
	 * Modifies post arguments before pushing to ensure the data is included for pages.
	 *
	 * @param array              $post_body The request body to send.
	 * @param \WP_Post           $post The WP_Post that is being pushed.
	 * @param array              $args Post args to push.
	 * @param ExternalConnection $connection The distributor connection being pushed to.
	 * @return array The post data to be inserted.
	 */
	public function handle_push_post( $post_body, $post, $args, $connection ) {
		$transport = $this->helper::get_transport_for_connection( $connection ); // external or internal
		$payload   = $this->helper::build_common_payload( $post );
		$post_body = array_merge( $post_body, $this->helper::adapt_for_transport( $payload, $transport ) );
		return $post_body;
	}

	/**
	 * Format a post's data for pull operations.
	 * Merges the Distributor common payload into the provided display data.
	 *
	 * @param array $display_data Post data array provided by Distributor before pulling.
	 * @return array Modified post data array including canonical payload fields.
	 */
	public function format_post_for_pull( $display_data ) {
		$post = get_post( $display_data['ID'] );
		$payload   = $this->helper::build_common_payload( $post );
		$display_data = custom_array_merge( $display_data, $payload );
		return $display_data;
	}

	/**
	 * Ensures data is preserved when pulling content from another site.
	 *
	 * @param array              $post_array The post data to be inserted.
	 * @param array              $remote_post_id The remote post ID.
	 * @param \WP_Post           $post The request that got the post.
	 * @param ExternalConnection $connection The Distributor connection pulling the post.
	 * @return array The post data to be inserted.
	 */
	public function handle_pull_post( $post_array, $remote_post_id, $post, $connection ) {
		if ( isset( $post->post_date ) ) {
			$post_array['post_date'] = $post->post_date;
		}
		if ( isset( $post->post_date_gmt ) ) {
			$post_array['post_date_gmt'] = $post->post_date_gmt;
		}

		$incoming = is_array( $post ) ? $post : (array) $post;
		foreach ( [ 'acf_post_fields', 'acf_term_fields', 'acf_post_fields_k', 'acf_term_fields_k', 'parent_ref', 'date', 'date_gmt', 'term_hierarchy', 'term_props', 'acf_relations' ] as $k ) {
			if ( isset( $incoming[ $k ] ) ) {
				$post_array[ $k ] = $incoming[ $k ];
			}
		}

		return $post_array;
	}

	/**
	 * Source site: add custom data to every subscription update.
	 * Runs when Distributor sends updates after you save a post.
	 *
	 * @param array    $post_body The post data being sent.
	 * @param \WP_Post $post The post being sent.
	 * @return array The updated post data.
	 */
	public function subscription_payload( $post_body, $post ) {
		$payload = $this->helper::build_common_payload( $post );
		return array_merge( $post_body, $this->helper::adapt_for_transport( $payload, 'subscription' ) );
	}

	/**
	 * Receiver site: apply subscription payload, then clear caches and regenerate CSS.
	 * Runs on every subscriber update.
	 *
	 * @param \WP_Post         $post The post being updated.
	 * @param \WP_REST_Request $request The request containing the payload.
	 * @return void
	 */
	public function on_subscription_receive( \WP_Post $post, \WP_REST_Request $request ) {
		$params = $request->get_params();
		$this->helper::apply_incoming_payload( $post->ID, $params );
		$this->helper::localize_elementor_media( $post->ID );
		$this->helper::after_content_update_caches( $post->ID );
	}

	/**
	 * Hook into all registered post types.
	 * Adds rest_after_insert_* hooks for each post type so payloads can be applied on insert.
	 *
	 * @return void
	 */
	public function hook_into_all_post_types() {
		// Get all registered post types
		$post_types = get_post_types( [], 'names' );

		// Loop through each post type and add the rest_after_insert hook dynamically
		foreach ( $post_types as $post_type ) {
			add_action( "rest_after_insert_{$post_type}", [ $this, 'apply_payload_on_rest_insert' ], 10, 3 );
		}
	}

	/**
	 * Apply a Distributor payload to a post immediately after it is inserted via the REST API.
	 * Only runs when the request contains the X-Distributor-Request header, which signals an incoming push.
	 * Delegates handling to on_subscription_receive().
	 *
	 * @param \WP_Post         $post     The newly created or updated post object.
	 * @param \WP_REST_Request $request  The REST API request object.
	 * @param bool             $creating Whether the post is being created (true) or updated (false).
	 * @return void
	 */
	public function apply_payload_on_rest_insert( $post, $request, $creating ) {
		$from_distributor = $request->get_header( 'X-Distributor-Request' );
		if ( ! $from_distributor ) {
			return;
		}
		$this->on_subscription_receive( $post, $request );
	}

	/**
	 * Receiver site: also clear caches after the first pull.
	 *
	 * @param int   $new_post_id The ID of the newly received post.
	 * @param mixed $connection The connection that received the post.
	 * @param array $post_array The post data that was received.
	 * @return void
	 */
	public function after_receive_any( $new_post_id, $connection, $post_array ) {
		$this->helper::apply_incoming_payload( $new_post_id, $post_array );
		$this->helper::localize_elementor_media( $new_post_id );
		$this->helper::after_content_update_caches( $new_post_id );
	}

	/**
	 * Add/Remove edit link in dashboard.
	 *
	 * Add or remove an edit link to the post/page action links on the post/pages list table.
	 *
	 * Fired by `post_row_actions` and `page_row_actions` filters.
	 *
	 * @access public
	 *
	 * @param array    $actions An array of row action links.
	 * @param \WP_Post $post The current post or page object.
	 *
	 * @return array An updated array of row action links.
	 */
	public function filter_admin_row_actions( $actions, $post ) {
		if ( isset( $actions['edit_with_elementor'] ) && get_post_meta( $post->ID, 'dt_syndicate_time', true ) && ! get_post_meta( $post->id, 'dt_unlinked', true ) ) {
			unset( $actions['edit_with_elementor'] );
		}

		return $actions;
	}

	/**
	 * Distribute all products that have not yet been syndicated.
	 *
	 * ## EXAMPLES
	 *
	 *     wp distributor distribute-products
	 *
	 * @when after_wp_load
	 */
	public function distribute_all_products() {

		if ( ! function_exists( '\Distributor\AutoDistribute\distribute_post_to_all_connections' ) ) {
			WP_CLI::error( 'Distributor plugin not loaded or AutoDistribute namespace missing.' );
		}

		$post_type = 'products';
		$query     = new WP_Query(
			[
				'post_type'      => $post_type,
				'post_status'    => [ 'any' ],
				'posts_per_page' => -1,
				'fields'         => 'ids',
			]
		);

		$total = count( $query->posts );

		if ( 0 === $total ) {
			WP_CLI::success( 'No products found to distribute.' );
			return;
		}

		WP_CLI::log( "Found {$total} products to distribute..." );

		$user_id = get_current_user_id() ? get_current_user_id() : 1;

		$distributed = 0;
		$errored = 0;
		$skipped = 0;

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

			// Skip if already has a connection map
			$map = get_post_meta( $post_id, 'dt_connection_map', true );
			if ( ( ! empty( $map['internal'] ) && 20 !== count( $map['internal'] ) ) || ( ! empty( $map['external'] ) && 20 !== count( $map['external'] ) ) ) {
				$skipped++;
				WP_CLI::log( "Skipping product ID {$post_id}: already distributed." );
				continue;
			}

			try {

				\Distributor\AutoDistribute\distribute_post_to_all_connections( $post, $user_id );
				$distributed++;
				WP_CLI::log( "Distributed product ID {$post_id}" );
			} catch ( \Throwable $e ) {
				$errored++;
				WP_CLI::warning( "Failed to distribute product ID {$post_id}: " . $e->getMessage() );
			}
		}

		WP_CLI::success( "Distribution complete. {$distributed} products distributed, skipped {$skipped} products, and {$errored} products failed." );
	}

	/**
	 * Automatically prelinks external WordPress connections to an existing remote post
	 * by matching the local post slug or title against candidate remote posts.
	 *
	 * This method expects to be called in a request context where POST data is available.
	 * It validates the incoming post ID and connection payload, loads the local post,
	 * iterates through external connections, and attempts to discover an existing
	 * remote post for each connection.
	 *
	 * When a match is found, the external connection map is updated with the remote
	 * post ID and a timestamp, and the result is persisted to post meta.
	 *
	 * Side effects:
	 * - Reads from $_POST
	 * - Reads and updates post meta (dt_connection_map)
	 * - Performs remote HTTP requests
	 * - Writes to the application log
	 *
	 * @return void
	 */
	public function prelink_external_posts_by_slug() {
		if ( empty( $_POST['postId'] ) || empty( $_POST['connections'] ) ) {
			return;
		}

		$post_id = absint( $_POST['postId'] );
		$post    = get_post( $post_id );

		if ( ! $post ) {
			return;
		}

		$connection_map = get_post_meta( $post_id, 'dt_connection_map', true );
		if ( ! is_array( $connection_map ) ) {
			$connection_map = [];
		}

		$connection_map['external'] = $connection_map['external'] ?? [];
		$connections = array_map( 'distributor_sanitize_connection', wp_unslash( $_POST['connections'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

		foreach ( $connections as $conn ) {

			if ( 'external' !== $conn['type'] ) {
				continue;
			}

			$connection_id = (int) $conn['id'];

			// Already linked
			if ( ! empty( $connection_map['external'][ $connection_id ]['post_id'] ) ) {
				continue;
			}

			$connection = \Distributor\ExternalConnection::instantiate( $connection_id );

			if ( ! ( $connection instanceof \Distributor\ExternalConnections\WordPressExternalConnection ) ) {
				continue;
			}

			$remote_id = $this->find_remote_post_by_slug_and_type( $post, $connection );

			if ( ! $remote_id ) {
				continue;
			}

			$connection_map['external'][ $connection_id ] = [
				'post_id' => (int) $remote_id,
				'time'    => time(),
			];

			write_log(
				sprintf(
					'Distributor auto-linked post %d to remote %d on connection %d',
					$post_id,
					$remote_id,
					$connection_id
				)
			);
		}

		update_post_meta( $post_id, 'dt_connection_map', $connection_map );
	}

	/**
	 * Attempts to locate an existing remote post for a given local post and connection
	 * using a two pass matching strategy.
	 *
	 * Matching strategy:
	 * 1. Strict comparison of the local post slug against the remote post_name
	 * 2. Fallback comparison using a sanitized version of the post title, allowing
	 *    draft and published remote posts
	 *
	 * The method queries a remote REST endpoint exposed by the external WordPress site
	 * and evaluates the returned candidate rows.
	 *
	 * @param \WP_Post                                                     $post The local WordPress post being matched.
	 * @param \Distributor\ExternalConnections\WordPressExternalConnection $connection The external WordPress connection used to query remote posts.
	 *
	 * @return int
	 *     The remote post ID if a match is found, otherwise 0.
	 */
	protected function find_remote_post_by_slug_and_type(
		\WP_Post $post,
		\Distributor\ExternalConnections\WordPressExternalConnection $connection
	) {
		$target_slug  = $post->post_name ? $post->post_name : '';
		$target_title = $post->post_title ? $post->post_title : '';
		$target_title_slug = $target_title
			? sanitize_title( $target_title )
			: '';

		$endpoint = untrailingslashit( $connection->base_url ) . '/cf/v1/find-post-candidates';
		$url = add_query_arg(
			[
				'slug' => $target_slug ? $target_slug : $target_title_slug,
				'post_type' => $post->post_type,
			],
			$endpoint
		);

		$response = \Distributor\Utils\remote_http_request(
			$url,
			$connection->auth_handler->format_get_args(
				[
					'timeout' => 15,
				]
			)
		);

		if ( is_wp_error( $response ) ) {
			return 0;
		}

		$raw_body = wp_remote_retrieve_body( $response );
		$data = json_decode( $raw_body, true );

		if ( empty( $data['rows'] ) || ! is_array( $data['rows'] ) ) {
			return 0;
		}

		// ---- Pass 1: strict slug match ----
		if ( $target_slug ) {
			foreach ( $data['rows'] as $row ) {
				if (
					! empty( $row['post_name'] ) &&
					$row['post_name'] === $target_slug
				) {
					return (int) $row['ID'];
				}
			}
		}

		// ---- Pass 2: title-based slug fallback ----
		if ( $target_title_slug ) {
			foreach ( $data['rows'] as $row ) {
				if ( empty( $row['post_title'] ) ) {
					continue;
				}

				$remote_title_slug = sanitize_title( $row['post_title'] );

				if ( $remote_title_slug === $target_title_slug && in_array( $row['post_status'], [ 'draft', 'publish' ], true ) ) {
					return (int) $row['ID'];
				}
			}
		}

		return 0;
	}

	/**
	 * Registers a REST API endpoint used to find candidate posts by slug or title.
	 *
	 * The endpoint accepts GET requests and requires the current user to have
	 * permission to edit posts. It performs a direct database query to locate
	 * posts of a specified post type that match either the provided slug exactly
	 * or a normalized title pattern.
	 *
	 * The response includes a limited list of candidate posts ordered by most
	 * recently modified.
	 *
	 * Endpoint:
	 * - Namespace: cf/v1
	 * - Route: find-post-candidates
	 *
	 * Query parameters:
	 * - slug (string, required)
	 * - post_type (string, optional, defaults to "post")
	 *
	 * @return void
	 */
	public function find_post_endpoint() {
		register_rest_route(
			'cf/v1',
			'find-post-candidates',
			[
				'methods'             => 'GET',
				'permission_callback' => function () {
					return current_user_can( 'edit_posts' );
				},
				'callback'            => function ( \WP_REST_Request $request ) {
					global $wpdb;

					$slug      = sanitize_text_field( $request->get_param( 'slug' ) );
					$post_type = $request->get_param( 'post_type' )
						? sanitize_key( $request->get_param( 'post_type' ) )
						: 'post';

					if ( ! $slug ) {
						return new \WP_Error(
							'missing_slug',
							'slug is required',
							[ 'status' => 400 ]
						);
					}

					// Normalize once
					$title_like = '%' . $wpdb->esc_like( str_replace( '-', ' ', $slug ) ) . '%';

					$rows = $wpdb->get_results(
						$wpdb->prepare(
							"
							SELECT
								ID,
								post_type,
								post_status,
								post_name,
								post_title
							FROM {$wpdb->posts}
							WHERE post_type = %s
							AND post_status NOT IN ('trash', 'auto-draft')
							AND (
								post_name = %s
								OR post_title LIKE %s
							)
							ORDER BY post_modified_gmt DESC
							LIMIT 25
							",
							$post_type,
							$slug,
							$title_like
						),
						ARRAY_A
					);

					return [
						'slug'      => $slug,
						'post_type' => $post_type,
						'count'    => count( $rows ),
						'rows'     => $rows,
					];
				},
			]
		);
	}

	/**
	 * Handle trashing of a distributed source post.
	 *
	 * Triggers deletion of all syndicated copies without forcing
	 * permanent removal on the remote sites.
	 *
	 * @param int $post_id The ID of the source post being trashed.
	 *
	 * @return void
	 */
	public function on_trash_distributed_post( $post_id ) {
		$this->delete_distributed_posts( $post_id, false );
	}

	/**
	 * Handle permanent deletion of a distributed source post.
	 *
	 * Triggers forced deletion of all syndicated copies on
	 * connected remote sites.
	 *
	 * @param int $post_id The ID of the source post being deleted.
	 *
	 * @return void
	 */
	public function on_delete_distributed_post( $post_id ) {
		$this->delete_distributed_posts( $post_id, true );
	}

	/**
	 * Delete syndicated posts from all Distributor connections
	 * when the source post is deleted or trashed.
	 *
	 * Skips revisions, autosaves, unlinked posts, and posts without
	 * a valid connection map.
	 *
	 * @param int  $post_id The ID of the source post.
	 * @param bool $force   Whether to force permanent deletion on remote sites.
	 *
	 * @return void
	 */
	public function delete_distributed_posts( $post_id, $force = true ) {
		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return;
		}

		// Optional, skip if unlinked
		if ( get_post_meta( $post_id, 'dt_unlinked', true ) ) {
			return;
		}

		$connection_map = get_post_meta( $post_id, 'dt_connection_map', true );

		if ( empty( $connection_map ) || ! is_array( $connection_map ) ) {
			return;
		}

		// External connections
		if ( ! empty( $connection_map['external'] ) && is_array( $connection_map['external'] ) ) {
			foreach ( $connection_map['external'] as $connection_id => $data ) {

				$remote_id = isset( $data['post_id'] ) ? (int) $data['post_id'] : 0;
				if ( ! $remote_id ) {
					continue;
				}

				$this->delete_external_post( (int) $connection_id, $remote_id, $force );
			}
		}
	}

	/**
	 * Delete a post from an external Distributor connection.
	 *
	 * Sends a DELETE request to the remote WordPress REST API endpoint
	 * for the syndicated post.
	 *
	 * @param int  $connection_id  The external connection ID.
	 * @param int  $remote_post_id The ID of the post on the remote site.
	 * @param bool $force          Whether to force permanent deletion.
	 *
	 * @return void
	 */
	protected function delete_external_post( int $connection_id, int $remote_post_id, bool $force ) {
		$connection = \Distributor\ExternalConnection::instantiate( $connection_id );

		if ( ! $connection || is_wp_error( $connection ) ) {
			return;
		}

		if ( ! ( $connection instanceof \Distributor\ExternalConnections\WordPressExternalConnection ) ) {
			return;
		}

		// NOTE: this assumes remote post type is "post".
		// If you are syncing multiple post types, we should store post_type in the connection_map,
		// or pass it along. For now, try posts endpoint, and fall back to CPT endpoint later.
		$endpoint = untrailingslashit( $connection->base_url )
			. '/'
			. \Distributor\ExternalConnections\WordPressExternalConnection::$namespace
			. '/posts/'
			. $remote_post_id;

		if ( $force ) {
			$endpoint = add_query_arg( [ 'force' => 'true' ], $endpoint );
		}

		$response = \Distributor\Utils\remote_http_request(
			$endpoint,
			$connection->auth_handler->format_post_args(
				[
					'method'  => 'DELETE',
					'timeout' => 20,
				]
			)
		);

		if ( is_wp_error( $response ) ) {
			return;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = wp_remote_retrieve_body( $response );

		if ( ! in_array( $code, [ 200, 204 ], true ) ) {
			return;
		}
	}
}
