<?php
/**
 * Surface registry - identity for non-post capturable pages (2.7.0 M1).
 *
 * The site-wide coverage programme captures pages that have no post ID:
 * the homepage, post-type archives, taxonomy term pages and author
 * archives. The positive-int post_id is the identity key through the
 * ENTIRE capture pipeline (proxy contract, callback routing, retry
 * queues, cache filenames), so rather than rework any of it, each
 * surface is allocated a persistent synthetic ID from a reserved high
 * range. Downstream, a surface is simply a "post" whose ID cannot
 * collide with a real one; DOIG_Surface_Meta routes its state to the
 * right storage. Architecture lock in ROADMAP.md "Site-wide coverage
 * programme" (2026-07-22 kickoff).
 *
 * Surface key grammar (stable, stored in the ID map - never rename):
 *
 *     home                      The site front page.
 *     archive:{post_type}       A post-type archive page.
 *     tax:{taxonomy}:{term_id}  One taxonomy term page.
 *     author:{user_id}          One author archive page.
 *
 * @package Dynamic_OG_Image_Generator
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class DOIG_Surfaces {

	/**
	 * Lowest synthetic surface ID. Far above any realistic post ID
	 * (a site would need 900 million posts to collide) while staying
	 * comfortably inside the proxy's INT UNSIGNED post_id column and
	 * every absint() in the pipeline.
	 */
	const ID_FLOOR = 900000000;

	/**
	 * Option holding the key -> synthetic-ID map. Allocation is
	 * append-only: IDs are never recycled, so a deleted term's cache
	 * file or in-flight job can never be claimed by a different surface.
	 */
	const IDS_OPTION = 'doig_surface_ids';

	// -------------------------------------------------------------------------
	// Identity
	// -------------------------------------------------------------------------

	/**
	 * Is this ID a synthetic surface ID (vs a real post ID)?
	 *
	 * @param int $id
	 * @return bool
	 */
	public static function is_surface_id( $id ) {
		return absint( $id ) >= self::ID_FLOOR;
	}

	/**
	 * The synthetic ID for a surface key, allocating one on first use.
	 *
	 * @param string $key      Surface key (see grammar above).
	 * @param bool   $allocate Allocate when unmapped. Pass false for
	 *                         read-only lookups (e.g. emission paths that
	 *                         must not write options on the front end).
	 * @return int 0 when unmapped and not allocating.
	 */
	public static function id_for_key( $key, $allocate = true ) {
		$map = (array) get_option( self::IDS_OPTION, array() );

		if ( isset( $map[ $key ] ) ) {
			return (int) $map[ $key ];
		}
		if ( ! $allocate ) {
			return 0;
		}

		$next        = $map ? max( $map ) + 1 : self::ID_FLOOR;
		$map[ $key ] = $next;
		update_option( self::IDS_OPTION, $map, false );

		DOIG_Logger::debug(
			sprintf( 'Surface %s allocated synthetic ID %d.', $key, $next )
		);

		return $next;
	}

	/**
	 * The surface key for a synthetic ID.
	 *
	 * @param int $id
	 * @return string '' when unknown.
	 */
	public static function key_for_id( $id ) {
		$found = array_search( absint( $id ), (array) get_option( self::IDS_OPTION, array() ), true );
		return is_string( $found ) ? $found : '';
	}

	/**
	 * The surface type: 'home' | 'archive' | 'tax' | 'author'.
	 *
	 * @param string $key
	 * @return string
	 */
	public static function type_of( $key ) {
		$colon = strpos( $key, ':' );
		return false === $colon ? $key : substr( $key, 0, $colon );
	}

	/**
	 * The surface key for the CURRENT front-end request, or '' when the
	 * request is not a covered surface (2.7.0 M4).
	 *
	 * Deliberate scope notes:
	 * - A STATIC front page is is_singular() and stays on the existing
	 *   per-post pipeline (its own sidebar Source controls); the 'home'
	 *   surface covers the latest-posts homepage, which has no post.
	 * - The posts page of a static-front configuration (is_home but not
	 *   front) is out of v1 scope, matching settings_groups() skipping
	 *   the 'post' archive.
	 * - Search, 404 and date archives are not surfaces.
	 *
	 * @return string
	 */
	public static function current_surface_key() {
		if ( is_singular() ) {
			return '';
		}
		if ( is_front_page() ) {
			return 'home';
		}
		if ( is_post_type_archive() ) {
			$pt = get_queried_object();
			return ( $pt instanceof WP_Post_Type ) ? 'archive:' . $pt->name : '';
		}
		if ( is_category() || is_tag() || is_tax() ) {
			$term = get_queried_object();
			return ( $term instanceof WP_Term ) ? 'tax:' . $term->taxonomy . ':' . (int) $term->term_id : '';
		}
		if ( is_author() ) {
			$user_id = (int) get_queried_object_id();
			return $user_id ? 'author:' . $user_id : '';
		}
		return '';
	}

	// -------------------------------------------------------------------------
	// Capture targets
	// -------------------------------------------------------------------------

	/**
	 * The public URL a surface's screenshot captures.
	 *
	 * @param string $key
	 * @return string '' when the surface no longer resolves (deleted term,
	 *                removed archive) - callers must treat that as
	 *                "skip, do not queue".
	 */
	public static function url_for_key( $key ) {
		$parts = explode( ':', $key );

		switch ( $parts[0] ) {
			case 'home':
				return home_url( '/' );

			case 'archive':
				$url = isset( $parts[1] ) ? get_post_type_archive_link( $parts[1] ) : false;
				return is_string( $url ) ? $url : '';

			case 'tax':
				if ( ! isset( $parts[1], $parts[2] ) ) {
					return '';
				}
				$url = get_term_link( (int) $parts[2], $parts[1] );
				return ( is_string( $url ) && ! is_wp_error( $url ) ) ? $url : '';

			case 'author':
				if ( ! isset( $parts[1] ) || ! get_userdata( (int) $parts[1] ) ) {
					return '';
				}
				return get_author_posts_url( (int) $parts[1] );
		}

		return '';
	}

	/**
	 * Human label for admin surfaces (Dashboard tables, log lines).
	 *
	 * @param string $key
	 * @return string
	 */
	public static function label_for_key( $key ) {
		$parts = explode( ':', $key );

		switch ( $parts[0] ) {
			case 'home':
				return __( 'Homepage', 'dynamic-og-image-generator' );

			case 'archive':
				$pt = isset( $parts[1] ) ? get_post_type_object( $parts[1] ) : null;
				return $pt
					/* translators: %s: post type plural label, e.g. "Products". */
					? sprintf( __( '%s archive', 'dynamic-og-image-generator' ), $pt->labels->name )
					: $key;

			case 'tax':
				$term = isset( $parts[2] ) ? get_term( (int) $parts[2] ) : null;
				return ( $term && ! is_wp_error( $term ) ) ? $term->name : $key;

			case 'author':
				$user = isset( $parts[1] ) ? get_userdata( (int) $parts[1] ) : false;
				return $user
					/* translators: %s: author display name. */
					? sprintf( __( 'Author: %s', 'dynamic-og-image-generator' ), $user->display_name )
					: $key;
		}

		return $key;
	}

	// -------------------------------------------------------------------------
	// Enumeration
	// -------------------------------------------------------------------------

	/**
	 * The toggleable groups the Settings UI lists (per the Q3 lock:
	 * per-taxonomy granularity, not per-term). Each row: group key,
	 * label, premium flag.
	 *
	 * Group keys ('home', 'archive:{pt}', 'taxonomy:{tax}', 'authors')
	 * are SETTINGS identifiers; instances_for_group() expands a group to
	 * the capturable surface keys.
	 *
	 * @return array<int, array{key: string, label: string, premium: bool}>
	 */
	public static function settings_groups() {
		$groups = array(
			array(
				'key'     => 'home',
				'label'   => __( 'Homepage', 'dynamic-og-image-generator' ),
				'premium' => false, // Q1 lock: the homepage is the free marketing story.
			),
		);

		foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $pt ) {
			// 'post' archives are the blog page special case - covered by
			// the home surface when the front page IS the blog; a static
			// front page + posts page is a follow-up nuance (see ROADMAP).
			if ( ! $pt->has_archive || 'post' === $pt->name ) {
				continue;
			}
			$groups[] = array(
				'key'     => 'archive:' . $pt->name,
				/* translators: %s: post type plural label. */
				'label'   => sprintf( __( '%s archive', 'dynamic-og-image-generator' ), $pt->labels->name ),
				'premium' => true,
			);
		}

		foreach ( get_taxonomies( array( 'public' => true ), 'objects' ) as $tax ) {
			if ( 'post_format' === $tax->name ) {
				continue;
			}
			$groups[] = array(
				'key'     => 'taxonomy:' . $tax->name,
				/* translators: %s: taxonomy plural label, e.g. "Categories". */
				'label'   => sprintf( __( '%s pages', 'dynamic-og-image-generator' ), $tax->labels->name ),
				'premium' => true,
			);
		}

		$groups[] = array(
			'key'     => 'authors',
			'label'   => __( 'Author archives', 'dynamic-og-image-generator' ),
			'premium' => true,
		);

		return $groups;
	}

	/**
	 * Every capturable surface the customer has ENABLED in Screenshot
	 * mode, as allocated synthetic IDs (2.7.0 M5). The shared enumeration
	 * for the daily refresh sweep and Regenerate All - the same
	 * quota-consent chain as the Settings save: group configured to
	 * Screenshot, premium groups only in premium builds, instances
	 * bounded (empty terms and postless authors excluded), and surfaces
	 * whose URL no longer resolves skipped.
	 *
	 * Custom and Off modes never appear here - neither needs a capture.
	 *
	 * @return int[] Synthetic surface IDs.
	 */
	public static function enabled_surface_ids() {
		$ids      = array();
		$surfaces = (array) DOIG_Settings::get( 'surfaces', array() );

		foreach ( self::settings_groups() as $group ) {
			$mode = DOIG_Source_Resolver::sanitize_surface_mode(
				$surfaces[ $group['key'] ] ?? DOIG_Source_Resolver::MODE_OFF
			);
			if ( DOIG_Source_Resolver::MODE_SCREENSHOT !== $mode ) {
				continue;
			}
			if ( $group['premium'] && ! DOIG_License::is_premium() ) {
				continue;
			}
			foreach ( self::instances_for_group( $group['key'] ) as $key ) {
				if ( '' === self::url_for_key( $key ) ) {
					continue;
				}
				$ids[] = self::id_for_key( $key );
			}
		}

		return $ids;
	}

	/**
	 * Surface IDs currently in one of the given statuses - the surface
	 * counterpart of the Dashboard's postmeta status queries (2.7.0 M5).
	 *
	 * @param string[] $statuses e.g. array( 'ready' ) or array( 'failed', 'quota_exceeded' ).
	 * @return int[]
	 */
	public static function surface_ids_with_status( array $statuses ) {
		$ids = array();

		foreach ( (array) get_option( DOIG_Surface_Meta::STATE_OPTION, array() ) as $key => $state ) {
			if ( in_array( (string) ( $state['_doig_status'] ?? '' ), $statuses, true ) ) {
				$id = self::id_for_key( $key, false );
				if ( $id ) {
					$ids[] = $id;
				}
			}
		}

		$terms = get_terms( array(
			'taxonomy'   => array_keys( get_taxonomies( array( 'public' => true ) ) ),
			'hide_empty' => false,
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin Dashboard, low frequency; matches only terms with DOIG state.
			'meta_query' => array(
				array( 'key' => '_doig_status', 'value' => $statuses, 'compare' => 'IN' ),
			),
		) );
		if ( ! is_wp_error( $terms ) ) {
			foreach ( $terms as $term ) {
				$id = self::id_for_key( 'tax:' . $term->taxonomy . ':' . (int) $term->term_id, false );
				if ( $id ) {
					$ids[] = $id;
				}
			}
		}

		$user_ids = get_users( array(
			'fields'     => 'ID',
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- admin Dashboard, low frequency; matches only users with DOIG state.
			'meta_query' => array(
				array( 'key' => '_doig_status', 'value' => $statuses, 'compare' => 'IN' ),
			),
		) );
		foreach ( $user_ids as $user_id ) {
			$id = self::id_for_key( 'author:' . (int) $user_id, false );
			if ( $id ) {
				$ids[] = $id;
			}
		}

		return $ids;
	}

	/**
	 * Surface IDs with an in-flight job older than the cutoff - the
	 * surface counterpart of poll_pending_jobs()' postmeta WP_Query
	 * (2.7.0 M2). Without this, surface jobs would be invisible to the
	 * polling fallback and stay pending forever on hosts that block the
	 * proxy callback.
	 *
	 * Scans the three surface stores: the option array (home/archives -
	 * a handful of rows), term meta (bounded by hide_empty and the
	 * pending status being rare), and user meta.
	 *
	 * @param int $cutoff Unix timestamp; jobs scheduled before this are due.
	 * @return int[] Synthetic surface IDs.
	 */
	public static function pending_surface_ids( $cutoff ) {
		$ids = array();

		// Option-backed surfaces (home, post-type archives).
		foreach ( (array) get_option( DOIG_Surface_Meta::STATE_OPTION, array() ) as $key => $state ) {
			if ( ( $state['_doig_status'] ?? '' ) === 'pending'
				&& ! empty( $state['_doig_job_id'] )
				&& (int) ( $state['_doig_scheduled_at'] ?? 0 ) < $cutoff
			) {
				$id = self::id_for_key( $key, false );
				if ( $id ) {
					$ids[] = $id;
				}
			}
		}

		// Term-backed surfaces. The pending+job_id pair only exists while
		// a job is mid-flight, so the meta_query matches a handful of
		// terms at most.
		$terms = get_terms( array(
			'taxonomy'   => array_keys( get_taxonomies( array( 'public' => true ) ) ),
			'hide_empty' => false,
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by the rare mid-flight pending state.
			'meta_query' => array(
				array( 'key' => '_doig_status', 'value' => 'pending' ),
				array( 'key' => '_doig_job_id', 'value' => '', 'compare' => '!=' ),
				array( 'key' => '_doig_scheduled_at', 'value' => $cutoff, 'compare' => '<', 'type' => 'NUMERIC' ),
			),
		) );
		if ( ! is_wp_error( $terms ) ) {
			foreach ( $terms as $term ) {
				$id = self::id_for_key( 'tax:' . $term->taxonomy . ':' . (int) $term->term_id, false );
				if ( $id ) {
					$ids[] = $id;
				}
			}
		}

		// User-backed surfaces (author archives).
		$user_ids = get_users( array(
			'fields'     => 'ID',
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by the rare mid-flight pending state.
			'meta_query' => array(
				array( 'key' => '_doig_status', 'value' => 'pending' ),
				array( 'key' => '_doig_job_id', 'value' => '', 'compare' => '!=' ),
				array( 'key' => '_doig_scheduled_at', 'value' => $cutoff, 'compare' => '<', 'type' => 'NUMERIC' ),
			),
		) );
		foreach ( $user_ids as $user_id ) {
			$id = self::id_for_key( 'author:' . (int) $user_id, false );
			if ( $id ) {
				$ids[] = $id;
			}
		}

		return $ids;
	}

	/**
	 * Expand a settings group to its capturable surface keys.
	 *
	 * Bounded on purpose: taxonomy expansion skips empty terms (their
	 * pages 404 or render empty - capturing burns quota for nothing) and
	 * authors expansion is limited to users with published posts.
	 *
	 * @param string $group_key A settings_groups() key.
	 * @return string[] Surface keys.
	 */
	public static function instances_for_group( $group_key ) {
		if ( 'home' === $group_key ) {
			return array( 'home' );
		}

		if ( 0 === strpos( $group_key, 'archive:' ) ) {
			return array( $group_key );
		}

		if ( 0 === strpos( $group_key, 'taxonomy:' ) ) {
			$taxonomy = substr( $group_key, strlen( 'taxonomy:' ) );
			$term_ids = get_terms( array(
				'taxonomy'   => $taxonomy,
				'hide_empty' => true,
				'fields'     => 'ids',
			) );
			if ( is_wp_error( $term_ids ) ) {
				return array();
			}
			$keys = array();
			foreach ( $term_ids as $term_id ) {
				$keys[] = 'tax:' . $taxonomy . ':' . (int) $term_id;
			}
			return $keys;
		}

		if ( 'authors' === $group_key ) {
			$user_ids = get_users( array(
				'has_published_posts' => true,
				'fields'              => 'ID',
			) );
			$keys = array();
			foreach ( $user_ids as $user_id ) {
				$keys[] = 'author:' . (int) $user_id;
			}
			return $keys;
		}

		return array();
	}
}
