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

/**
 * The texts of a content, pulled out of the JSON they live in and put back into it.
 *
 * WHAT IT SOLVES. Everything an Aura page says is written in `_aipb_blocks`: one JSON string, one
 * post meta. For us that is the right shape — it is what makes a section portable, revisionable and
 * regenerable — but for every translation plugin in existence it is a wall. WPML in front of that
 * field shows the translator a blob of markup and CSS with the copy buried somewhere inside it, and
 * nobody translates that. The texts are not hidden by accident: they are hidden because nothing
 * outside this plugin knows the shape.
 *
 * So this class is the shape, told to whoever asks. It does TWO things and nothing else:
 *
 *  - EXTRACT: hand back every translatable string of a content as a flat list, each with a stable
 *    name, a human title and the kind of editor it deserves;
 *  - PUT BACK: given those names with other texts against them, rewrite the JSON leaving everything
 *    that is not text exactly where it was.
 *
 * IT KNOWS NOTHING ABOUT WPML, and that is the point. The list it produces is equally the input of
 * a WPML package ({@see AIPB_WPML}), of a Polylang screen, of an export to XLIFF, of a translation
 * paid for in credits, of a global search and replace. Writing the extraction once and the
 * integrations on top is the difference between one feature and four rewrites of the same walk.
 *
 * WHAT COUNTS AS A TEXT is decided by the SCHEMA, not by looking at the values. The schema already
 * says, field by field, what each value is — `text`, `richtext`, `url`, `image`, `bool` — because
 * `AIPB_AI_Provider::derive_schema()` wrote it from the placeholders. Reading the schema is
 * therefore exact where guessing would not be: it never offers a colour or a switch for
 * translation, and it never offers `hero_img_srcset`, which is not even a field.
 *
 * THE NAME OF A STRING IS ITS ADDRESS, and it has to survive: a translation is attached to the
 * name, so a name that changes is a translation that comes unstuck. It is built out of the block's
 * id — which is born in the Studio and stays with the block for its whole life — and the field's
 * key, never out of a position. The one exception is the rows of a repeater, which have nothing but
 * their order to be identified by: reordering the rows of a repeater moves the translations with
 * the positions, and that is a known and accepted limit, not an oversight.
 */
class AIPB_Strings {

	/**
	 * Which field types are text, and what kind of editor each one asks for.
	 *
	 * The three names on the right are WPML's — LINE, AREA, VISUAL — and they are used verbatim
	 * because they are what its translation editor understands. They also happen to say the only
	 * three things a translator needs to know: one line, several lines, or formatted content whose
	 * markup must come out the other side intact.
	 *
	 * WHAT IS NOT HERE is as deliberate: `image` (a media file, and translating a URL as if it were
	 * a sentence is how you end up with a broken image), `color`, `bool`, `number` and `shortcode`
	 * (an identifier, not a text). A `url` IS here, because a call to action pointing at /contatti
	 * has to be able to point at /en/contact.
	 */
	const EDITOR = array(
		'text'     => 'LINE',
		'textarea' => 'AREA',
		'richtext' => 'VISUAL',
		'url'      => 'LINE',
	);

	/** Keys the engine computes from the chosen image: values, but never fields, and never texts. */
	const DERIVED = '/_(srcset|sizes|w|h)$/';

	/* ------------------------------- Reading -------------------------------- */

	/**
	 * Every translatable string of a content, in reading order.
	 *
	 * @param int $post_id
	 *
	 * @return array<int,array{name:string,value:string,type:string,title:string}>
	 */
	public static function extract( $post_id ) {
		$found = array();

		self::walk( self::blocks( $post_id ), function ( $name, $value, $type, $title ) use ( &$found ) {
			$found[] = array( 'name' => $name, 'value' => $value, 'type' => $type, 'title' => $title );
			return null; // reading only: nothing is rewritten
		} );

		return $found;
	}

	/**
	 * A content's blocks, whatever shape it is stored in.
	 *
	 * A page keeps a STACK in `_aipb_blocks`; a component (pattern, ai_section) keeps a SINGLE
	 * template in three separate metas. They are the same thing seen twice, and everything above
	 * this line is spared the difference: a component comes back as a stack of one.
	 *
	 * @param int $post_id
	 *
	 * @return array
	 */
	public static function blocks( $post_id ) {
		$raw = get_post_meta( (int) $post_id, AIPB_META_BLOCKS, true );
		if ( $raw ) {
			$blocks = json_decode( (string) $raw, true );
			if ( is_array( $blocks ) ) {
				return $blocks;
			}
		}

		$template = (string) get_post_meta( (int) $post_id, AIPB_META_TEMPLATE, true );
		if ( '' === $template ) {
			return array();
		}

		$schema = json_decode( (string) get_post_meta( (int) $post_id, AIPB_META_SCHEMA, true ), true );
		$values = json_decode( (string) get_post_meta( (int) $post_id, AIPB_META_VALUES, true ), true );

		return array( array(
			'type'     => 'inline',
			'id'       => '',
			'name'     => '',
			'template' => $template,
			'schema'   => is_array( $schema ) ? $schema : array( 'fields' => array() ),
			'values'   => is_array( $values ) ? $values : array(),
		) );
	}

	/** Does this content keep a stack of blocks, rather than a single template? */
	public static function is_stack( $post_id ) {
		$raw = get_post_meta( (int) $post_id, AIPB_META_BLOCKS, true );

		return $raw && is_array( json_decode( (string) $raw, true ) );
	}

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

	/**
	 * Copies a content's blocks onto ANOTHER content, with the texts replaced.
	 *
	 * This is what a finished translation is: the same page, the same markup, the same CSS, the
	 * same images, and other words. Which is also why the template and the schema are copied
	 * verbatim rather than rebuilt — they are the structure, and the structure does not have a
	 * language.
	 *
	 * @param int                  $from The content the texts were extracted from.
	 * @param int                  $to   The content to write.
	 * @param array<string,string> $map  name => translated text. Missing names keep the original.
	 *
	 * @return bool Whether there was anything to copy.
	 */
	public static function copy( $from, $to, $map ) {
		$from   = (int) $from;
		$to     = (int) $to;
		$blocks = self::blocks( $from );
		if ( ! $blocks || ! $to ) {
			return false;
		}

		$blocks = self::walk( $blocks, function ( $name, $value, $type, $title ) use ( $map ) {
			return isset( $map[ $name ] ) ? (string) $map[ $name ] : null;
		} );

		if ( self::is_stack( $from ) ) {
			update_post_meta( $to, AIPB_META_BLOCKS, wp_slash( wp_json_encode( $blocks ) ) );

			return true;
		}

		// A component: the three metas travel together, or the translation is a template with no
		// values (an empty section) or values with no template (nothing at all).
		update_post_meta( $to, AIPB_META_TEMPLATE, wp_slash( (string) get_post_meta( $from, AIPB_META_TEMPLATE, true ) ) );
		update_post_meta( $to, AIPB_META_SCHEMA, wp_slash( (string) get_post_meta( $from, AIPB_META_SCHEMA, true ) ) );
		update_post_meta( $to, AIPB_META_VALUES, wp_slash( wp_json_encode( $blocks[0]['values'] ) ) );

		return true;
	}

	/* -------------------------------- The walk ------------------------------- */

	/**
	 * Visits every translatable string of a stack of blocks.
	 *
	 * ONE walk for three jobs — listing, translating on the way out, writing a translation — because
	 * three walks over the same tree is three chances for them to disagree about what a string is,
	 * and a string only one of them can see is a string that gets registered and never translated
	 * (or translated and never shown).
	 *
	 * @param array    $blocks
	 * @param callable $visit function( string $name, string $value, string $type, string $title ):
	 *                        the replacement text, or null to leave the value as it is.
	 *
	 * @return array The blocks, with whatever the visitor returned written into them.
	 */
	public static function walk( $blocks, $visit ) {
		if ( ! is_array( $blocks ) ) {
			return array();
		}

		foreach ( $blocks as $i => $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}
			if ( 'catalog' === ( isset( $block['type'] ) ? $block['type'] : '' ) && empty( $block['values'] ) && ! empty( $block['fields'] ) && is_array( $block['fields'] ) ) {
				$block['values'] = $block['fields'];
			}
			// A `ref` block holds no values: its texts belong to the component it points at, and
			// they are that component's own to declare.
			if ( empty( $block['values'] ) || ! is_array( $block['values'] ) ) {
				continue;
			}

			// An id that is THERE is used even when it is empty: that is how a component — a single
			// template with no stack around it — says that its strings need no prefix. A block with
			// no id at all is another matter, and falls back to its position for want of anything
			// better.
			$id     = isset( $block['id'] ) ? (string) $block['id'] : 'b' . $i;
			$title  = isset( $block['name'] ) ? (string) $block['name'] : '';
			$fields = ( isset( $block['schema']['fields'] ) && is_array( $block['schema']['fields'] ) )
				? $block['schema']['fields']
				: array();

			$blocks[ $i ]['values'] = self::visit( $block['values'], $fields, $id, $title, $visit );
			if ( 'catalog' === ( isset( $block['type'] ) ? $block['type'] : '' ) ) {
				$blocks[ $i ]['fields'] = $blocks[ $i ]['values'];
			}
		}

		return $blocks;
	}

	/**
	 * One level of values: the fields the schema declares, then whatever it does not.
	 *
	 * @param array    $values
	 * @param array    $fields The schema's fields (or a repeater's subfields) for this level.
	 * @param string   $path   The address of this level.
	 * @param string   $title  What to call this level when showing it to a person.
	 * @param callable $visit
	 *
	 * @return array
	 */
	protected static function visit( $values, $fields, $path, $title, $visit ) {
		$handled = array();

		foreach ( $fields as $field ) {
			if ( ! is_array( $field ) || empty( $field['key'] ) ) {
				continue;
			}
			$key = (string) $field['key'];
			if ( ! isset( $values[ $key ] ) ) {
				continue;
			}
			$handled[ $key ] = true;

			$type  = isset( $field['type'] ) ? (string) $field['type'] : 'text';
			$label = self::label( $field, $key );

			if ( 'repeater' === $type ) {
				$subs = ( isset( $field['subfields'] ) && is_array( $field['subfields'] ) ) ? $field['subfields'] : array();
				$values[ $key ] = self::rows( $values[ $key ], $subs, self::path( $path, $key ), self::crumb( $title, $label ), $visit );
				continue;
			}

			$editor = self::EDITOR;
			if ( ! array_key_exists( $type, $editor ) ) {
				continue; // an image, a colour, a switch: it has no words in it
			}

			$values[ $key ] = self::leaf(
				$values[ $key ],
				self::path( $path, $key ),
				$editor[ $type ],
				self::crumb( $title, $label ),
				$visit
			);
		}

		return self::unschematised( $values, $handled, $path, $title, $visit );
	}

	/**
	 * The values the schema says nothing about.
	 *
	 * TWO real cases, both of which would otherwise come out untranslated with nobody to notice:
	 *
	 *  - NESTED repeaters. `{{#loop source="prop" name="field.subitems"}}` inside another loop is a
	 *    list inside a row, and `derive_schema()` does not describe it — it describes the outer
	 *    repeater's subfields and stops. The rows are in the values all the same, and the words
	 *    inside them are as visible on the page as any other;
	 *  - sections generated before the schema had the shape it has today, whose values are there
	 *    with nothing to explain them.
	 *
	 * With no schema to ask, the type has to be GUESSED, and the guessing errs on the side of
	 * leaving things alone: anything that looks like an address, a colour, a number or a switch is
	 * skipped, because offering a machine-translated image URL to a translator is not a missing
	 * feature, it is a broken image.
	 */
	protected static function unschematised( $values, $handled, $path, $title, $visit ) {
		foreach ( $values as $key => $value ) {
			if ( isset( $handled[ $key ] ) ) {
				continue;
			}
			$key = (string) $key;

			if ( is_array( $value ) ) {
				$values[ $key ] = self::rows( $value, array(), self::path( $path, $key ), self::crumb( $title, self::humanise( $key ) ), $visit );
				continue;
			}

			if ( ! self::looks_like_text( $key, $value ) ) {
				continue;
			}

			$values[ $key ] = self::leaf( $value, self::path( $path, $key ), 'LINE', self::crumb( $title, self::humanise( $key ) ), $visit );
		}

		return $values;
	}

	/** The rows of a repeater, each one a level of its own. */
	protected static function rows( $rows, $subfields, $path, $title, $visit ) {
		if ( ! is_array( $rows ) ) {
			return $rows;
		}

		foreach ( $rows as $i => $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$rows[ $i ] = self::visit( $row, $subfields, self::path( $path, (string) $i ), $title . ' ' . ( (int) $i + 1 ), $visit );
		}

		return $rows;
	}

	/** One string: shown to the visitor, and replaced with what it hands back. */
	protected static function leaf( $value, $name, $type, $title, $visit ) {
		if ( ! is_string( $value ) || '' === trim( $value ) ) {
			return $value; // an empty field is not a string to translate: it is a field nobody filled in
		}

		$new = call_user_func( $visit, $name, $value, $type, $title );

		return ( is_string( $new ) && '' !== $new ) ? $new : $value;
	}

	/* -------------------------------- Naming -------------------------------- */

	/**
	 * Is this loose value a text?
	 *
	 * Only asked where the schema is silent. Every "no" here is a value that stays as it is, which
	 * is the safe answer: a text left untranslated is visible and gets reported, a URL translated
	 * into another URL is a 404 nobody attributes to the translation.
	 */
	protected static function looks_like_text( $key, $value ) {
		if ( ! is_string( $value ) || '' === trim( $value ) ) {
			return false;
		}
		if ( preg_match( self::DERIVED, $key ) ) {
			return false; // photo_srcset, photo_w: computed from the image
		}
		if ( preg_match( '#^(https?:)?//#i', $value ) || 0 === strpos( $value, 'data:' ) ) {
			return false; // an address, almost certainly a media file
		}
		if ( preg_match( '/^#[0-9a-f]{3,8}$/i', $value ) ) {
			return false; // a colour
		}
		if ( preg_match( '/^-?\d+([.,]\d+)?$/', $value ) ) {
			return false; // a number, and therefore also the 0/1 of a switch
		}

		return true;
	}

	/** One step down the address. The separator only appears where there is something to separate. */
	protected static function path( $prefix, $step ) {
		return '' !== $prefix ? $prefix . ':' . $step : (string) $step;
	}

	/** A field's readable name: the one the schema carries, or one made out of the key. */
	protected static function label( $field, $key ) {
		$label = isset( $field['label'] ) ? trim( (string) $field['label'] ) : '';

		return '' !== $label ? $label : self::humanise( $key );
	}

	protected static function humanise( $key ) {
		return ucfirst( str_replace( array( '_', '-' ), ' ', (string) $key ) );
	}

	/**
	 * What a string is called on the translator's screen.
	 *
	 * "Hero · Title" and not "hero_title": whoever is translating is looking at a list of a hundred
	 * rows with no page around them, and the only thing telling them that this one is the big
	 * heading of the first section is this string.
	 */
	protected static function crumb( $title, $label ) {
		$title = trim( (string) $title );

		return '' !== $title ? $title . ' · ' . $label : $label;
	}
}
