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

/**
 * Where the image of a piece of content created by Aura comes from.
 *
 * WHAT IT SOLVES. Aura publishes eight planets and the section prints `<img src="">`: a slider of
 * planets with no planets. It is the most visible fault in the whole product, because it is not a
 * technical detail — it is a hole in the page.
 *
 * THE ORDER, which is a product decision and not a technical one:
 *
 *  1. what the content ALREADY has (featured image, or a filled image field): if somebody chose
 *     it, they were right and it is not touched;
 *  2. the site's MEDIA LIBRARY: the customer's own photos are worth more than any stock, and they
 *     are already there, already theirs, already licensed;
 *  3. STOCK, as a fallback.
 *
 * STOCK DOES NOT ENTER THE DATABASE. The remote address is kept in a meta and served from there:
 * the Media Library stays the customer's, not a dump of placeholders nobody chose and nobody will
 * be able to tell apart in six months. The price of that choice has to be said: those images live
 * on a server that is not ours, and the day it does not answer the page shows a hole. It is a
 * declared fallback, not a result.
 *
 * WHY OPENVERSE. It is the WordPress project's own openly-licensed media search, it asks for no
 * key, and it can filter by licence. We ask for `cc0` and `pdm` — public domain or equivalent —
 * and not `by`: a CC-BY has to be attributed, and an attribution the plugin does not print is a
 * licence the customer's site breaks without anybody having told them.
 */
class AIPB_Images {

	/** Where the fallback address is kept: a meta, not an attachment. */
	const META = '_aipb_image_url';

	/** Openverse, filtered down to the licences that ask for no attribution. */
	const API = 'https://api.openverse.org/v1/images/';

	/** How long a search is remembered. Free photos do not change, requests do. */
	const CACHE = DAY_IN_SECONDS;

	/**
	 * The stand-in image services a model reaches for when it has no real photo to hand.
	 *
	 * The list is matched against the HOST, never against the whole address: `picsum.photos` in the
	 * middle of a path is somebody's article about placeholders, not a placeholder.
	 */
	const PLACEHOLDER_HOSTS = array(
		'picsum.photos',
		'placehold.co',
		'placeholder.com',
		'via.placeholder.com',
		'placeimg.com',
		'placekitten.com',
		'dummyimage.com',
		'loremflickr.com',
		'source.unsplash.com',
		'baconmockup.com',
		'unsplash.it',
	);

	/**
	 * How many stand-ins one section is allowed to turn into real photographs.
	 *
	 * Each one can be a network call, and they happen in the request that the person who pressed
	 * "generate" is sitting in front of. Four covers what is actually looked at — a hero and a row
	 * of three cards — and a gallery of twenty keeps its stand-ins rather than adding two minutes of
	 * waiting to a page that is otherwise finished.
	 */
	const RESOLVE_MAX = 4;

	/** Words kept from an alt text used as a search: a sentence finds nothing, three words do. */
	const QUERY_WORDS = 6;

	/**
	 * Searches for an image: at home first, then outside.
	 *
	 * @return string a URL, or '' when nothing was found.
	 */
	public static function find( $query ) {
		$query = trim( wp_strip_all_tags( (string) $query ) );
		if ( '' === $query ) {
			return '';
		}

		$mine = self::from_library( $query );

		return '' !== $mine ? $mine : self::from_stock( $query );
	}

	/* --------------------- The stand-ins inside a section --------------------- */

	/**
	 * Whether an address points at a stand-in image rather than at a photograph somebody chose.
	 */
	public static function is_placeholder( $url ) {
		$url = trim( (string) $url );
		if ( '' === $url || ! preg_match( '#^https?://#i', $url ) ) {
			return false;
		}

		$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
		$host = preg_replace( '/^www\./', '', $host );

		return in_array( $host, self::PLACEHOLDER_HOSTS, true );
	}

	/**
	 * Turns the stand-in images a freshly generated section carries into real photographs.
	 *
	 * WHAT IT SOLVES, and it is the first thing anybody sees. The DSL teaches the model the image
	 * placeholder by example, and the example carries an address:
	 * `{{i:photo|https://picsum.photos/1200/700}}`. So a section with a photograph in it is born
	 * pointing at a random picture on somebody else's server — and unless a human goes and picks one
	 * image at a time, that is what the customer's live page shows. A page can be perfectly composed
	 * and still look unfinished for this reason alone.
	 *
	 * Everything needed was already here and simply not wired up: {@see find()} looks in the
	 * customer's own Media Library first and only then at stock, and it was reachable only from the
	 * content types created by a plan — never from the images of a section.
	 *
	 * WHAT IT DOES NOT DO. It does not touch an address the model chose deliberately: only the known
	 * stand-in services are replaced ({@see PLACEHOLDER_HOSTS}). It does not fail a generation — when
	 * nothing is found the stand-in stays, which is exactly the behaviour of before. And it does not
	 * put anything into the Media Library, for the reason set out at the top of this file.
	 *
	 * @param array  $values   the section's initial values, as parsed from the placeholders.
	 * @param string $template the section's code, which says which keys are images.
	 * @return array the values, with the stand-ins it managed to replace swapped out.
	 */
	public static function resolve_placeholders( $values, $template ) {
		if ( ! is_array( $values ) || ! $values || '' === trim( (string) $template ) ) {
			return $values;
		}

		/**
		 * Whether a generated section should go looking for real photographs.
		 *
		 * Turning it off leaves the stand-ins in place: no network call leaves the site.
		 *
		 * @param bool $enabled
		 */
		if ( ! apply_filters( 'aipb_fill_placeholder_images', true ) ) {
			return $values;
		}

		/**
		 * How many images one section may resolve. @param int $max */
		$budget = (int) apply_filters( 'aipb_placeholder_images_max', self::RESOLVE_MAX );
		if ( $budget < 1 ) {
			return $values;
		}

		// The section's own heading, as the last resort for a query: an image with no alt in a
		// section called "Our bakery" is better served by "Our bakery" than by the word "photo".
		$topic = self::topic_of( $template, $values );

		// Top-level images: {{i:key}}.
		foreach ( self::image_keys( $template ) as $key ) {
			if ( $budget < 1 ) {
				return $values;
			}
			if ( ! isset( $values[ $key ] ) || ! self::is_placeholder( $values[ $key ] ) ) {
				continue;
			}
			$url = self::find( self::query_for( $key, $values, $topic ) );
			if ( '' !== $url ) {
				$values[ $key ] = $url;
				--$budget;
			}
		}

		// Repeater rows: one photo per card, each searched for with ITS OWN row. Handing the same
		// query to all of them would come back with the same picture three times, which reads worse
		// than three stand-ins because it looks like a bug rather than like something unfinished.
		foreach ( self::repeater_image_keys( $template ) as $repeater => $keys ) {
			if ( empty( $values[ $repeater ] ) || ! is_array( $values[ $repeater ] ) ) {
				continue;
			}
			foreach ( $values[ $repeater ] as $i => $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}
				foreach ( $keys as $key ) {
					if ( $budget < 1 ) {
						return $values;
					}
					if ( ! isset( $row[ $key ] ) || ! self::is_placeholder( $row[ $key ] ) ) {
						continue;
					}
					$url = self::find( self::query_for( $key, $row, $topic ) );
					if ( '' !== $url ) {
						$values[ $repeater ][ $i ][ $key ] = $url;
						--$budget;
					}
				}
			}
		}

		return $values;
	}

	/** The top-level keys the template uses as images: {{i:key}}. */
	protected static function image_keys( $template ) {
		return preg_match_all( '/\{\{i:([a-zA-Z0-9_]+)\}\}/', (string) $template, $m )
			? array_values( array_unique( $m[1] ) )
			: array();
	}

	/**
	 * The image subfields of every repeater, as repeater name => list of keys.
	 *
	 * @return array<string,list<string>>
	 */
	protected static function repeater_image_keys( $template ) {
		$out = array();

		if ( ! preg_match_all( '/\{\{#loop\s+([^}]*?)\}\}(.*?)\{\{\/loop\}\}/s', (string) $template, $loops, PREG_SET_ORDER ) ) {
			return $out;
		}

		foreach ( $loops as $loop ) {
			// Only the repeaters whose rows are the section's own (source="prop"): a loop over the
			// site's content takes its images from the content, and has nothing to do with here.
			if ( preg_match( '/\bsource\s*=\s*"([^"]*)"/', $loop[1], $src ) && 'prop' !== $src[1] ) {
				continue;
			}
			if ( ! preg_match( '/\bname\s*=\s*"([^"]+)"/', $loop[1], $nm ) ) {
				continue;
			}
			$name = $nm[1];
			if ( 0 === strpos( $name, 'field.' ) ) {
				continue;
			}
			if ( preg_match_all( '/\{\{i:field\.([a-zA-Z0-9_]+)\}\}/', $loop[2], $im ) ) {
				$out[ $name ] = array_values( array_unique( $im[1] ) );
			}
		}

		return $out;
	}

	/**
	 * What to search for, for one image.
	 *
	 * The best signal is the alt text sitting next to it: the DSL requires the model to write one,
	 * and it describes the SUBJECT. It is cut down to a handful of words because a search engine
	 * given a whole sentence — "a woman smiling at a standing desk in a bright open-plan office" —
	 * comes back with nothing at all, and nothing is how an image silently stays a stand-in.
	 */
	protected static function query_for( $key, $bag, $topic ) {
		$candidates = array();

		if ( isset( $bag[ $key . '_alt' ] ) ) {
			$candidates[] = $bag[ $key . '_alt' ];
		}
		// A card names its subject in its own row: it beats the section's heading by a mile.
		foreach ( array( 'title', 'name', 'heading', 'label' ) as $k ) {
			if ( isset( $bag[ $k ] ) ) {
				$candidates[] = $bag[ $k ];
			}
		}
		$candidates[] = $topic;

		foreach ( $candidates as $candidate ) {
			$words = self::first_words( $candidate );
			if ( '' !== $words ) {
				return $words;
			}
		}

		return '';
	}

	/** The first few words of a string, with the markup and the placeholders taken out. */
	protected static function first_words( $text ) {
		$text = wp_strip_all_tags( (string) $text );
		$text = preg_replace( '/\{\{.*?\}\}/', ' ', $text );
		$text = trim( preg_replace( '/\s+/', ' ', $text ) );
		if ( '' === $text ) {
			return '';
		}

		$words = array_slice( explode( ' ', $text ), 0, self::QUERY_WORDS );

		return implode( ' ', $words );
	}

	/** What the section is about: its first heading, or failing that its first text value. */
	protected static function topic_of( $template, $values ) {
		if ( preg_match( '/<h[1-3][^>]*>(.*?)<\/h[1-3]>/is', (string) $template, $m ) ) {
			// The heading is usually a placeholder — {{t:title}} — so its VALUE is what carries words.
			if ( preg_match( '/\{\{[tcs]:([a-zA-Z0-9_]+)\}\}/', $m[1], $ph ) && isset( $values[ $ph[1] ] ) ) {
				$words = self::first_words( $values[ $ph[1] ] );
				if ( '' !== $words ) {
					return $words;
				}
			}
			$words = self::first_words( $m[1] );
			if ( '' !== $words ) {
				return $words;
			}
		}

		return '';
	}

	/**
	 * Gives a piece of content its fallback image, if it does not have one already.
	 *
	 * @return string the chosen URL, or '' when nothing was found.
	 */
	public static function attach( $post_id, $query = '' ) {
		$post_id = (int) $post_id;
		if ( self::has_image( $post_id ) ) {
			return '';
		}

		$url = self::find( '' !== trim( (string) $query ) ? $query : get_the_title( $post_id ) );
		if ( '' !== $url ) {
			update_post_meta( $post_id, self::META, $url );
		}

		return $url;
	}

	/**
	 * Goes looking for images for the content of a type that has none.
	 *
	 * IT EXISTS BECAUSE `attach()` RUNS AT CREATION TIME, and content born before this class
	 * existed — or imported, or written by hand — never saw it go past. Those stay with an empty
	 * image forever, and nobody connects that hole to a feature that is right there.
	 *
	 * It costs no tokens: it does not go through the model, it looks at the Media Library and then
	 * at Openverse. It has a per-run cap because every image-less piece of content is a network
	 * call, and fifty calls in a row inside one WordPress request is a timeout.
	 *
	 * @return array{seen:int,found:int,left:int}
	 */
	public static function fill_missing( $post_type, $max = 20 ) {
		$post_type = sanitize_key( $post_type );
		$out       = array( 'seen' => 0, 'found' => 0, 'left' => 0 );

		if ( '' === $post_type || ! post_type_exists( $post_type ) ) {
			return $out;
		}

		$without     = self::without_image( $post_type );
		$out['left'] = count( $without );

		foreach ( array_slice( $without, 0, max( 1, (int) $max ) ) as $id ) {
			$out['seen']++;
			$out['left']--;

			if ( '' !== self::attach( $id ) ) {
				$out['found']++;
			}
		}

		return $out;
	}

	/**
	 * The ids of a type's content that has neither a featured image nor a fallback.
	 *
	 * The trash stays out: hunting for a photo for something somebody threw away is work and
	 * network calls spent on what will never be seen.
	 *
	 * @return list<int>
	 */
	public static function without_image( $post_type ) {
		$ids = get_posts( array(
			'post_type'        => sanitize_key( $post_type ),
			'post_status'      => array( 'publish', 'draft', 'pending', 'future', 'private' ),
			'numberposts'      => 200,
			'fields'           => 'ids',
			'suppress_filters' => false,
		) );

		$out = array();
		foreach ( (array) $ids as $id ) {
			if ( ! self::has_image( (int) $id ) ) {
				$out[] = (int) $id;
			}
		}

		return $out;
	}

	/**
	 * The site's Media Library.
	 *
	 * WordPress's search over an attachment looks at the title, the caption and the description —
	 * not at the file name, which is where the one useful word almost always ends up. We search
	 * both ways and take the first that answers: a vaguely relevant photo of the customer's beats
	 * a perfect photo of a stranger's.
	 */
	public static function from_library( $query ) {
		$args = array(
			'post_type'        => 'attachment',
			'post_status'      => 'inherit',
			'post_mime_type'   => 'image',
			'numberposts'      => 1,
			'fields'           => 'ids',
			'suppress_filters' => false,
		);

		$found = get_posts( array_merge( $args, array( 's' => $query ) ) );

		if ( ! $found ) {
			// By file name: `_wp_attached_file` holds the path, and a LIKE over it is the only way
			// of finding `sacher-cake.jpg` when the title is "IMG_4821".
			$found = get_posts( array_merge( $args, array(
				'meta_query' => array( array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					'key'     => '_wp_attached_file',
					'value'   => sanitize_title( $query ),
					'compare' => 'LIKE',
				) ),
			) ) );
		}

		if ( ! $found ) {
			return '';
		}

		$src = wp_get_attachment_image_src( (int) $found[0], 'large' );

		return $src ? (string) $src[0] : '';
	}

	/**
	 * Stock, through the API and without downloading anything.
	 *
	 * An error here is not a product error: it returns an empty string and the page comes out
	 * without that image, which is exactly how it was before this class existed. The timeout is
	 * short on purpose — this call sits inside the creation of N pieces of content, and eight
	 * ten-second waits would turn an instant operation into a minute and a half of nothing.
	 */
	public static function from_stock( $query ) {
		$key    = 'aipb_img_' . md5( mb_strtolower( $query ) );
		$cached = get_transient( $key );
		if ( false !== $cached ) {
			return (string) $cached;
		}

		$response = wp_remote_get(
			add_query_arg( array(
				'q'            => rawurlencode( $query ),
				'license'      => 'cc0,pdm',
				'page_size'    => 1,
				'mature'       => 'false',
				'aspect_ratio' => 'wide',
			), self::API ),
			array(
				'timeout' => 6,
				'headers' => array( 'Accept' => 'application/json' ),
			)
		);

		$out = '';
		if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) {
			$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
			if ( is_array( $body ) && ! empty( $body['results'][0]['url'] ) ) {
				$out = esc_url_raw( (string) $body['results'][0]['url'] );
			}
		}

		// The blank is remembered too: a search that returns nothing must not be repeated on every
		// save, and trying again in an hour costs nothing.
		set_transient( $key, $out, '' === $out ? HOUR_IN_SECONDS : self::CACHE );

		return $out;
	}

	/** Does this content already have an image, its own or a fallback? */
	protected static function has_image( $post_id ) {
		return (bool) get_post_thumbnail_id( $post_id )
			|| '' !== (string) get_post_meta( $post_id, self::META, true );
	}
}
