<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Aura, translated with WPML.
 *
 * THE DECISION THIS FILE IS MADE OF. A multilingual site is two problems, not one, and only the
 * second is ours:
 *
 *  1. the LANGUAGES — the URLs, the switcher, hreflang, the queries filtered per language, the
 *     menus, the taxonomies, the media, the sitemap. It is a plugin's worth of work, it is solved,
 *     and anybody who solves it again spends months to arrive at a version that argues with the
 *     one the customer already has installed;
 *  2. the TEXTS INSIDE A SECTION, which nobody but us can reach, because they live in a JSON string
 *     inside a post meta. WPML in front of `_aipb_blocks` shows a blob and gives up, and it is
 *     right to: it has no way of knowing which of those thousands of characters are words and which
 *     are markup.
 *
 * So we do the second and hand it to whoever does the first. The result is that translating an Aura
 * page is the same gesture as translating any other page — the + under the flag, the translation
 * editor, one row per text — with no screen of ours to learn, no second place where the site's
 * languages are declared, and nothing that breaks the day WPML is not installed.
 *
 * HOW IT PLUGS IN. WPML has a public API for page builders, the same one Elementor, Beaver Builder
 * and Bricks arrive through, and it is three hooks:
 *
 *  - `wpml_page_builder_support_required` — we say we exist, and WPML makes us a "strategy" and a
 *    namespace of our own for the strings;
 *  - `wpml_page_builder_register_strings` — WPML asks a post what there is to translate, and we
 *    answer with the list {@see AIPB_Strings::extract()} produces;
 *  - `wpml_page_builder_string_translated` — the translation is finished, and we write it into the
 *    translated post's meta.
 *
 * Everything else — the editor, the automatic translation, the translation memory, the "needs
 * updating" state, the jobs, the translators' accounts — is WPML's, already written, already paid
 * for by whoever bought it.
 *
 * IT GOES BOTH WAYS, and it has to. The translations live in WPML — that is the design, and it is
 * why a page reads them instead of keeping a copy — but the Studio opens on a translated page like
 * on any other, and rewriting an Italian paragraph is a thing to do while looking at the Italian
 * page, not in a table of rows. So a save there hands the words back to WPML as its translations
 * ({@see push_translation()}), marked finished, and the two surfaces stop being two.
 *
 * AND A SAFETY NET UNDERNEATH, which is what makes the difference between "it works" and
 * "compatible". A translated page can arrive without our meta: WPML has a duplicate mode that does
 * not go through a translation job at all, and an FSE template is not a translatable post type in
 * the first place, so nobody ever asks it for its strings. Those draw from their ORIGINAL and the
 * texts are swapped ON THE WAY OUT, reading the same strings from the same package — so no page
 * ever comes out empty or half in the wrong language, whatever mode the site is in.
 *
 * That last step reads the package only over words that are still in the SOURCE language. Over a
 * translation it would be reading WPML's memory across the top of what somebody had just written
 * into the page, on every view, so that saving appeared to do nothing at all.
 */
class AIPB_WPML {

	/**
	 * The name of our page-builder strategy inside WPML.
	 *
	 * It also becomes the strings' namespace, so it must not change once a site has translations:
	 * the strings are looked up by (kind, name), and renaming the kind orphans every translation
	 * ever made. It is the plugin's name and it stays the plugin's name.
	 */
	const KIND = 'Aura';

	/** WPML's code for "this translation is finished". */
	const COMPLETE = 10;

	/**
	 * True while we are writing what WPML has just handed us.
	 *
	 * Writing a translation ends in a `wp_update_post` (the snapshot), which fires `save_post`,
	 * which would push those same words straight back into WPML as though somebody had typed them.
	 * They are identical, so nothing would break — but it would mark as finished, in WPML's own
	 * records, strings that only came back out of it a moment earlier.
	 */
	protected static $writing = false;

	public static function init() {
		// The declaration has to be there even before WPML asks, so it is registered whatever the
		// load order: it is a filter, it costs nothing until somebody applies it.
		add_filter( 'wpml_page_builder_support_required', array( __CLASS__, 'declare_support' ) );
		add_filter( 'wpml_active_string_package_kinds', array( __CLASS__, 'declare_kind' ) );

		add_action( 'wpml_page_builder_register_strings', array( __CLASS__, 'register_strings' ), 10, 2 );
		add_action( 'wpml_page_builder_string_translated', array( __CLASS__, 'write_translation' ), 10, 5 );

		// The post body is OURS: post_content holds the snapshot we regenerate on every save (see
		// AIPB_Render::sync_post_content). Without this the translator is shown the whole compiled
		// page — markup, inline CSS and all — next to the fields, and translates the same texts
		// twice into a copy that the next save overwrites.
		add_filter( 'wpml_pb_is_page_builder_page', array( __CLASS__, 'is_ours' ), 10, 2 );

		// Every save of a content of ours, in either direction: an original offers its strings, a
		// translation hands back the words somebody has just written in it.
		add_action( 'save_post', array( __CLASS__, 'on_save' ), 20, 2 );
	}

	/** Is WPML on this site at all? Everything below is a no-op when it is not. */
	public static function active() {
		return defined( 'ICL_SITEPRESS_VERSION' );
	}

	/* ----------------------------- The declaration --------------------------- */

	/**
	 * @param array $names
	 *
	 * @return array
	 */
	public static function declare_support( $names ) {
		$names   = is_array( $names ) ? $names : array();
		$names[] = self::KIND;

		return array_values( array_unique( $names ) );
	}

	/**
	 * Says that the strings under this namespace belong to a plugin that is installed and running.
	 *
	 * Without it WPML sees a set of strings whose owner it cannot identify and offers to delete
	 * them as leftovers — which, on a site that has just been translated, is every translation of
	 * every page.
	 *
	 * @param array $kinds
	 *
	 * @return array
	 */
	public static function declare_kind( $kinds ) {
		$kinds = is_array( $kinds ) ? $kinds : array();

		$kinds[ sanitize_title( self::KIND ) ] = array(
			'title'  => self::KIND,
			'slug'   => sanitize_title( self::KIND ),
			'plural' => self::KIND,
		);

		return $kinds;
	}

	/**
	 * @param bool    $is
	 * @param WP_Post $post
	 *
	 * @return bool
	 */
	public static function is_ours( $is, $post ) {
		if ( $post && isset( $post->ID ) && AIPB_Ownership::is_aura( $post->ID ) ) {
			return true;
		}

		return $is;
	}

	/* ------------------------------ Registering ------------------------------ */

	/**
	 * WPML is asking a post what there is to translate.
	 *
	 * TWO CHECKS BEFORE ANSWERING, and both matter. The action carries the package of whichever
	 * strategy fired it — on a site with Gutenberg and Aura both registered it fires more than once
	 * — so a handler that does not look at the kind writes its strings into somebody else's
	 * namespace. And a post with no Aura content has nothing to say: answering with an empty
	 * package would create it, and an empty package is a row in the translation manager that can
	 * never be finished.
	 *
	 * @param WP_Post $post
	 * @param array   $package
	 */
	public static function register_strings( $post, $package ) {
		if ( ! is_array( $package ) || ! isset( $package['kind'] ) || self::KIND !== $package['kind'] ) {
			return;
		}
		if ( ! $post || ! isset( $post->ID ) || ! AIPB_Ownership::has_own_content( $post->ID ) ) {
			return;
		}
		// A translation has no strings of its own to offer: its values ARE the translation. Taking
		// them as originals would open a second package whose source is somebody else's output.
		if ( self::source_id( $post->ID ) !== (int) $post->ID ) {
			return;
		}

		self::register( (int) $post->ID, $package );
	}

	/**
	 * Registers a content's strings under its package.
	 *
	 * The three calls are one cycle and have to stay together: the middle one adds, and the two
	 * around it are what makes a re-registration a REPLACEMENT rather than an accumulation. Without
	 * the closing call, a field renamed or a section deleted leaves its string behind for ever —
	 * translated, paid for, and attached to nothing.
	 *
	 * @param int   $post_id
	 * @param array $package
	 */
	protected static function register( $post_id, $package ) {
		do_action( 'wpml_start_string_package_registration', $package );

		foreach ( AIPB_Strings::extract( $post_id ) as $string ) {
			do_action(
				'wpml_register_string',
				$string['value'],
				$string['name'],
				$package,
				$string['title'],
				$string['type']
			);
		}

		do_action( 'wpml_delete_unused_package_strings', $package );
	}

	/**
	 * A content of ours has been saved. Which of the two directions this is depends on the post.
	 *
	 * AN ORIGINAL offers its strings — but only when WPML is not already asking for them itself,
	 * which it does for every post type it translates. What is left is the FSE templates
	 * (wp_template, wp_template_part): they are not translatable posts — there is no English copy of
	 * a template and there should not be — but an Aura header and footer live in them, and on a
	 * multilingual site those are the two most visible strings on every page.
	 *
	 * A TRANSLATION hands its words back to WPML. See {@see push_translation()}.
	 *
	 * @param int     $post_id
	 * @param WP_Post $post
	 */
	public static function on_save( $post_id, $post ) {
		if ( ! self::active() || ! $post || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return;
		}
		if ( ! AIPB_Ownership::has_own_content( $post_id ) ) {
			return;
		}

		if ( self::source_id( $post_id ) !== (int) $post_id ) {
			self::push_translation( (int) $post_id );

			return;
		}

		// Anything WPML translates on its own account is registered by WPML on its own account.
		if ( apply_filters( 'wpml_is_translated_post_type', null, $post->post_type ) ) {
			return;
		}

		self::register( (int) $post_id, self::package( (int) $post_id ) );
	}

	/**
	 * The words written into a translated page, handed back to WPML as its translations.
	 *
	 * WHAT IT SOLVES, and it is the half without which the integration is only half true. The
	 * translations live in WPML: that is the whole design, and it is why the page reads them rather
	 * than keeping its own copy. But the Studio can be opened on a translated page like on any
	 * other — and it should be, because rewriting a paragraph in Italian is a thing to do while
	 * looking at the Italian page, not in a table of rows. Without this, that save wrote into the
	 * post's meta a text nothing would ever read again: WPML still held the old translation, and the
	 * page went on showing it. Saving looked as though it had done nothing at all.
	 *
	 * So the two surfaces are now the same surface. Change a text in WPML and the page changes;
	 * change it on the page and WPML has it, with the string marked finished — because somebody
	 * writing a sentence into a page has finished translating it.
	 *
	 * ONLY WHAT DIFFERS FROM THE ORIGINAL is written. A field left exactly as the source is not a
	 * translation, it is a field nobody has touched, and recording it as translated would tell WPML
	 * that a page is done when half of it has not been read.
	 *
	 * @param int $post_id A translation.
	 */
	protected static function push_translation( $post_id ) {
		// It came out of WPML a moment ago (see $writing): putting it back would be telling WPML
		// something it has just told us.
		if ( self::$writing ) {
			return;
		}

		$source   = self::source_id( $post_id );
		$language = self::language_of( $post_id );
		if ( ! $source || $source === (int) $post_id || '' === $language ) {
			return;
		}

		$original = array();
		foreach ( AIPB_Strings::extract( $source ) as $string ) {
			$original[ $string['name'] ] = $string['value'];
		}

		$package = self::package( $source );

		foreach ( AIPB_Strings::extract( $post_id ) as $string ) {
			$name = $string['name'];

			// A string the original does not have has nothing to be a translation OF. It happens
			// when the two have drifted — a section regenerated on one side only — and the answer is
			// to leave it alone rather than to invent a place for it.
			if ( ! isset( $original[ $name ] ) || $original[ $name ] === $string['value'] ) {
				continue;
			}

			// The row WPML filed that string under. Nothing comes back for a string it has never
			// been told about, and there is nothing sensible to do with that: a translation needs
			// an original to be a translation of.
			$id = (int) apply_filters( 'wpml_string_id_from_package', null, $package, $name, $original[ $name ] );
			if ( $id <= 0 ) {
				continue;
			}

			do_action( 'wpml_add_string_translation', $id, $language, $string['value'], self::COMPLETE );
		}
	}

	/** Which language a post is written in. Empty when nobody knows, which is answer enough. */
	protected static function language_of( $post_id ) {
		$details = apply_filters( 'wpml_post_language_details', null, (int) $post_id );

		return ( is_array( $details ) && ! empty( $details['language_code'] ) ) ? (string) $details['language_code'] : '';
	}

	/**
	 * The package a content's strings live in.
	 *
	 * The shape is WPML's own, copied from `WPML_PB_API_Hooks_Strategy::get_package_key()` down to
	 * the title, and copied on purpose: this same package is built by WPML when it asks for the
	 * strings and by us when we look them up at render time. If the two descriptions differed, the
	 * second would not find what the first registered — or worse, would find it and rename it on
	 * every page view.
	 *
	 * @param int $post_id
	 *
	 * @return array
	 */
	public static function package( $post_id ) {
		$post_id = (int) $post_id;

		return array(
			'kind'    => self::KIND,
			'name'    => $post_id,
			'title'   => 'Page Builder Page ' . $post_id,
			'post_id' => $post_id,
		);
	}

	/* ------------------------------ Writing back ----------------------------- */

	/**
	 * The translation is finished: write it.
	 *
	 * WPML hands over every string of the package with, for each language, a status and a text. Only
	 * the COMPLETE ones are taken: a string still in the translator's hands is not a translation,
	 * and writing it would publish a draft.
	 *
	 * @param string  $kind
	 * @param int     $translated_post_id
	 * @param WP_Post $original_post
	 * @param array   $string_translations [ name => [ lang => [ status, value ] ] ]
	 * @param string  $lang
	 */
	public static function write_translation( $kind, $translated_post_id, $original_post, $string_translations, $lang ) {
		if ( self::KIND !== $kind || ! $original_post || ! isset( $original_post->ID ) ) {
			return;
		}

		$map = array();
		foreach ( (array) $string_translations as $name => $languages ) {
			if ( ! isset( $languages[ $lang ]['value'] ) ) {
				continue;
			}
			$translation = $languages[ $lang ];
			if ( isset( $translation['status'] ) && (int) $translation['status'] !== self::COMPLETE ) {
				continue;
			}
			$map[ (string) $name ] = (string) $translation['value'];
		}

		self::$writing = true;

		if ( ! AIPB_Strings::copy( (int) $original_post->ID, (int) $translated_post_id, $map ) ) {
			self::$writing = false;

			return;
		}

		// The translated page is Aura's too. Said explicitly rather than left to be inferred: the
		// copy arrives with a post_content full of blocks (our snapshot), and an inference looking
		// at that alone concludes Gutenberg and hands the page to the wrong editor for ever.
		AIPB_Ownership::claim( (int) $translated_post_id, AIPB_Ownership::AURA );

		// And its snapshot has to speak the new language: it is what the SEO plugins read and what
		// survives the plugin being deactivated. Regenerating it here is the only moment at which
		// the translated meta and the translated post are both in place.
		AIPB_Render::sync_post_content( (int) $translated_post_id );

		self::$writing = false;
	}

	/* -------------------------- On the way out ------------------------------- */

	/**
	 * The blocks as they should read in the language being served.
	 *
	 * Idempotent by construction: WPML answers with the translation of the string registered under
	 * that name, so a page whose meta already hold the translated text gets handed the same text
	 * back. That is what lets this run over everything without having to know first whether the
	 * write-back has been through.
	 *
	 * @param array $blocks
	 * @param int   $post_id The content the blocks came from.
	 *
	 * @return array
	 */
	public static function translate( $blocks, $post_id ) {
		if ( ! self::active() || ! $post_id || ! $blocks ) {
			return $blocks;
		}

		// THE WORDS OF A TRANSLATION ARE ALREADY THE RIGHT ONES. They were put there either by WPML
		// finishing a job or by somebody editing that page in the Studio, and reading the package
		// over the top of them would throw away exactly the edit that has just been made — silently,
		// on every page view, so that saving looks as though it did nothing.
		//
		// What is left for this method is the case it was written for: blocks that are still in the
		// SOURCE language. A page drawing from its original because its own copy was never written,
		// and an FSE template, which is not a translatable post and never gets one.
		$post_id = (int) $post_id;
		if ( self::source_id( $post_id ) !== $post_id ) {
			return $blocks;
		}

		$package = self::package( $post_id );

		return AIPB_Strings::walk( $blocks, function ( $name, $value, $type, $title ) use ( $package ) {
			$translated = apply_filters( 'wpml_translate_string', $value, $name, $package );

			return is_string( $translated ) ? $translated : null;
		} );
	}

	/**
	 * ONE block's values, in the language being served.
	 *
	 * The same call as {@see translate()}, for the one place that holds a block and not a page: the
	 * filter's AJAX endpoint, which finds the section a list belongs to and re-renders it on its
	 * own. Without this, the second page of a catalogue comes back with its «no results» message in
	 * the original language, and only whoever clicks twice ever sees it.
	 *
	 * @param array $block
	 * @param int   $post_id
	 *
	 * @return array
	 */
	public static function translate_values( $block, $post_id ) {
		$values = ( isset( $block['values'] ) && is_array( $block['values'] ) ) ? $block['values'] : array();
		if ( ! self::active() || ! $values ) {
			return $values;
		}

		$translated = self::translate( array( $block ), $post_id );

		return ( isset( $translated[0]['values'] ) && is_array( $translated[0]['values'] ) ) ? $translated[0]['values'] : $values;
	}

	/* ------------------------------ Which post ------------------------------- */

	/**
	 * The same content in the language being served.
	 *
	 * Used wherever we hold an ID that came out of a template: the section a shortcode embeds, the
	 * component a `ref` block points at, the navigation a header draws. Those IDs were written when
	 * the page was built, in one language, and left alone they would pull the Italian section into
	 * the English page — the page translated, and the shared header not.
	 *
	 * SAFE WITH NO WPML: with nobody on the filter it returns the ID it was given, so the call can
	 * be made unconditionally at the point where the ID is used, which is the only point where
	 * forgetting it is impossible.
	 *
	 * @param int $post_id
	 *
	 * @return int
	 */
	public static function object_id( $post_id ) {
		$post_id = (int) $post_id;
		if ( ! $post_id ) {
			return 0;
		}

		$type = get_post_type( $post_id );
		if ( ! $type ) {
			return $post_id;
		}

		// true = if there is no translation, keep the original. A header in one language is a
		// blemish; a header that has vanished is a broken site.
		$found = apply_filters( 'wpml_object_id', $post_id, $type, true );

		return $found ? (int) $found : $post_id;
	}

	/**
	 * The content an ID descends from: itself, when it is not a translation.
	 *
	 * It is the address of the package: the strings are registered once, against the original, and
	 * every language reads them from there.
	 *
	 * @param int $post_id
	 *
	 * @return int
	 */
	public static function source_id( $post_id ) {
		$post_id = (int) $post_id;
		if ( ! self::active() || ! $post_id ) {
			return $post_id;
		}

		$type = get_post_type( $post_id );
		if ( ! $type ) {
			return $post_id;
		}

		$original = apply_filters( 'wpml_original_element_id', null, $post_id, 'post_' . $type );

		return $original ? (int) $original : $post_id;
	}

	/**
	 * Which content to actually draw a post from.
	 *
	 * Normally the post itself. But a translation can exist WITHOUT our meta — WPML's duplicate
	 * mode creates the post and copies nothing we asked to be left alone, and a translation job
	 * that has not been finished has not written anything yet — and a page that renders nothing is
	 * the worst possible answer: the visitor gets a blank page, and whoever built the site sees a
	 * plugin that "does not work with WPML".
	 *
	 * In that case we draw from the ORIGINAL and translate on the way out. The page is up, in the
	 * right language wherever a translated string exists, and in the source language where one does
	 * not — which is exactly what a partially translated site should look like.
	 *
	 * @param int $post_id
	 *
	 * @return int
	 */
	public static function content_id( $post_id ) {
		$post_id = (int) $post_id;
		if ( ! self::active() || ! $post_id ) {
			return $post_id;
		}

		$source = self::source_id( $post_id );
		if ( $source === $post_id ) {
			return $post_id;
		}

		return AIPB_Ownership::has_own_content( $post_id ) ? $post_id : $source;
	}

	/**
	 * The language being served, for whoever has to carry it somewhere else.
	 *
	 * @return string Empty when there is no WPML, which is a language nobody has to be told about.
	 */
	public static function language() {
		if ( ! self::active() ) {
			return '';
		}

		$lang = apply_filters( 'wpml_current_language', null );

		return is_string( $lang ) ? $lang : '';
	}
}
