<?php
namespace TitanCart\Modules\Pages;

defined( 'ABSPATH' ) || exit;

/**
 * Page Registry — the canonical catalog of TitanCart-aware storefront pages.
 *
 * Roadmap #2 (Page Manager) — Session 1.
 *
 * WHY THIS EXISTS
 * ───────────────
 * TitanCart historically created its storefront pages two ad-hoc ways, both in
 * Plugin.php:
 *   1. Migration 23 — a hardcoded array of { title, slug, content(shortcode),
 *      option } that inserts any missing page and records the resulting ID in a
 *      `titancart_page_*` option (homepage, blog, gift_cards).
 *   2. Per-feature migrations — each new shortcode-backed page got its own
 *      one-off migration re-implementing the SAME idempotent pattern
 *      (Migration 34, testimonials).
 *
 * Pages an admin created (or deleted) by hand were invisible to the plugin: no
 * registry of "which page hosts which shortcode," no way to recreate a deleted
 * page, no UI to point a feature at a different page. This class is the single
 * source of truth that fixes that — every core and (later) extension page is
 * described once here, and the ONE idempotent create/adopt implementation lives
 * in ensure().
 *
 * PORTABILITY (Rule 4)
 * ────────────────────
 * The registry DATA (core_pages(), all(), normalize(), get()) is pure PHP arrays
 * with zero WordPress coupling — it describes what pages exist, not how they are
 * hosted. The WordPress-page HOSTING layer (resolve_page_id(), page_status(),
 * ensure()) is isolated in clearly-named methods, because "a page is a WP post"
 * is a WordPress storefront concern. When the engine is extracted, the descriptor
 * list travels intact and only the hosting methods are re-implemented.
 *
 * DESCRIPTOR SHAPE
 * ────────────────
 *   key         string  Stable registry key (sanitize_key).
 *   title       string  Default post_title on creation.
 *   slug        string  Default post_name (sanitize_title) on creation.
 *   shortcode   string  The shortcode the page hosts, e.g. '[titancart_gift_cards]'
 *                       ('' for structural/rewrite pages).
 *   option      string  The titancart_page_* option that stores the hosting page
 *                       ID ('' when the page is bound another way — reading
 *                       settings for structural, rewrite rules for virtual).
 *   owner       string  'core' or an extension slug. Core keys are authoritative.
 *   type        string  'structural' | 'shortcode' | 'rewrite'.
 *                         - structural: a real WP page bound via the WordPress
 *                           reading settings (homepage → page_on_front, blog →
 *                           page_for_posts). Creatable; NOT auto-created by the
 *                           registry migration (Migration 23 owns the binding).
 *                         - shortcode: a real WP page whose content is a
 *                           TitanCart shortcode. Creatable + option-bound. These
 *                           are the primary registry consumers.
 *                         - rewrite: a VIRTUAL page served by
 *                           StorefrontModule::register_extra_rewrite_rules (shop,
 *                           cart, checkout, account, wishlist, search, order
 *                           received). NOT a WP page — listed only so the Page
 *                           Manager UI can show the complete surface. Never
 *                           created, never option-bound.
 *   label       string  Human label for the admin surface (defaults to title).
 *   description string  Short UI hint.
 *
 * EXTENSIONS
 * ──────────
 * Extensions contribute their own descriptors through the
 * `titancart_registered_pages` filter (see all()). Each extension appends only
 * its own page(s) and only while active. A filtered entry can add a NEW key but
 * cannot silently redefine a core-owned page's creation semantics. Porting the
 * existing extension pages (Wishlist+, etc.) to this filter is Session 3.
 *
 * @package TitanCart\Modules\Pages
 */

class PageRegistry {

	/**
	 * Filter through which extensions register their own page descriptors.
	 * Receives array<int,array> (the core descriptors) and returns the same,
	 * with each extension's descriptor(s) appended.
	 */
	const REGISTERED_PAGES_FILTER = 'titancart_registered_pages';

	/** Valid descriptor types. */
	const TYPES = [ 'structural', 'shortcode', 'rewrite' ];

	/**
	 * The core page catalog — the authoritative list of pages TitanCart core is
	 * aware of. Verified against the live source (Migration 23 / Migration 34 /
	 * StorefrontModule shortcodes + rewrite rules) at S343.
	 *
	 * NOTE: the affiliate dashboard ([titancart_affiliate_dashboard]) is NOT a
	 * page consumer — that shortcode was RETIRED in S235; the affiliate area now
	 * lives inside the customer My Account. (The roadmap named it as the first
	 * consumer; it is stale.)
	 *
	 * @return array<int,array<string,string>>
	 */
	public static function core_pages(): array {
		$pages = [
			// ── Structural (bound via WordPress reading settings) ──────────────
			[
				'key'         => 'homepage',
				'title'       => 'Homepage',
				'slug'        => 'homepage',
				'shortcode'   => '',
				'option'      => '',
				'owner'       => 'core',
				'type'        => 'structural',
				'label'       => 'Homepage',
				'description' => 'Static front page (bound via the WordPress reading settings: page_on_front).',
			],
			[
				'key'         => 'blog',
				'title'       => 'Blog',
				'slug'        => 'blog',
				'shortcode'   => '',
				'option'      => '',
				'owner'       => 'core',
				'type'        => 'structural',
				'label'       => 'Blog',
				'description' => 'Posts page (bound via the WordPress reading settings: page_for_posts).',
			],

			// ── Shortcode-backed real WP pages (the primary registry consumers) ─
			// Gift Cards is a Pro module (src/Modules/GiftCards is stripped from the
			// Lite package): its page is registered — and therefore auto-created —
			// only on the Pro edition (KI-125, S478). Filtered below.
			[
				'key'         => 'gift_cards',
				'title'       => 'Gift Cards',
				'slug'        => 'gift-cards',
				'shortcode'   => '[titancart_gift_cards]',
				'option'      => 'titancart_page_gift_cards',
				'owner'       => 'core',
				'type'        => 'shortcode',
				'label'       => 'Gift Cards',
				'description' => 'Storefront gift-card purchase page.',
			],
			[
				'key'         => 'faq',
				'title'       => 'FAQ',
				'slug'        => 'faq',
				'shortcode'   => '[titancart_faq]',
				'option'      => 'titancart_page_faq',
				'owner'       => 'core',
				'type'        => 'shortcode',
				'label'       => 'FAQ',
				'description' => 'Frequently asked questions — the accordion built from Storefront → FAQ.',
			],
			[
				'key'         => 'testimonials',
				'title'       => 'Testimonials',
				'slug'        => 'testimonials',
				'shortcode'   => '[titancart_testimonials]',
				'option'      => 'titancart_page_testimonials',
				'owner'       => 'core',
				'type'        => 'shortcode',
				'label'       => 'Testimonials',
				'description' => 'Customer testimonials page.',
			],

			// ── Rewrite-rule-backed virtual pages (NOT WP pages; shown for a
			//    complete surface in the Page Manager). Their shortcodes exist as
			//    fallbacks but the canonical delivery is the rewrite rule in
			//    StorefrontModule::register_extra_rewrite_rules. Never created. ──
			[
				'key'         => 'shop',
				'title'       => 'Shop',
				'slug'        => 'shop',
				'shortcode'   => '[titancart_shop]',
				'option'      => '',
				'owner'       => 'core',
				'type'        => 'rewrite',
				'label'       => 'Shop',
				'description' => 'Product catalog. Served by TitanCart rewrite rules — no WordPress page required.',
			],
			[
				'key'         => 'cart',
				'title'       => 'Cart',
				'slug'        => 'cart',
				'shortcode'   => '[titancart_cart]',
				'option'      => '',
				'owner'       => 'core',
				'type'        => 'rewrite',
				'label'       => 'Cart',
				'description' => 'Shopping cart. Served by TitanCart rewrite rules — no WordPress page required.',
			],
			[
				'key'         => 'checkout',
				'title'       => 'Checkout',
				'slug'        => 'checkout',
				'shortcode'   => '[titancart_checkout]',
				'option'      => '',
				'owner'       => 'core',
				'type'        => 'rewrite',
				'label'       => 'Checkout',
				'description' => 'Checkout flow. Served by TitanCart rewrite rules — no WordPress page required.',
			],
			[
				'key'         => 'account',
				'title'       => 'My Account',
				'slug'        => 'account',
				'shortcode'   => '[titancart_account]',
				'option'      => '',
				'owner'       => 'core',
				'type'        => 'rewrite',
				'label'       => 'My Account',
				'description' => 'Customer account dashboard. Served by TitanCart rewrite rules — no WordPress page required.',
			],
			[
				'key'         => 'wishlist',
				'title'       => 'Wishlist',
				'slug'        => 'wishlist',
				'shortcode'   => '[titancart_wishlist]',
				'option'      => '',
				'owner'       => 'core',
				'type'        => 'rewrite',
				'label'       => 'Wishlist',
				'description' => 'Customer wishlist. Served by TitanCart rewrite rules — no WordPress page required.',
			],
			[
				'key'         => 'search',
				'title'       => 'Search',
				'slug'        => 'search',
				'shortcode'   => '',
				'option'      => '',
				'owner'       => 'core',
				'type'        => 'rewrite',
				'label'       => 'Search Results',
				'description' => 'Product search results. Served by TitanCart rewrite rules — no WordPress page required.',
			],
			[
				'key'         => 'order_received',
				'title'       => 'Order Received',
				'slug'        => 'order-received',
				'shortcode'   => '',
				'option'      => '',
				'owner'       => 'core',
				'type'        => 'rewrite',
				'label'       => 'Order Received',
				'description' => 'Post-checkout confirmation. Served by TitanCart rewrite rules — no WordPress page required.',
			],
		];

		// KI-125: drop Pro-only pages on the Lite edition (the gift-cards module
		// is not in the Lite package; Edition::is_pro() exists in both builds).
		if ( ! \TitanCart\Core\Edition::is_pro() ) {
			$pages = array_values( array_filter( $pages, function ( $p ) {
				return 'gift_cards' !== $p['key'];
			} ) );
		}

		return $pages;
	}

	/**
	 * The full, normalized registry: core pages plus any pages contributed by
	 * extensions through the `titancart_registered_pages` filter, de-duplicated
	 * by key. Core descriptors are authoritative for their own keys.
	 *
	 * @return array<int,array<string,string>>
	 */
	public static function all(): array {
		$pages = [];

		foreach ( self::core_pages() as $descriptor ) {
			$normalized = self::normalize( $descriptor );
			if ( '' === $normalized['key'] ) {
				continue;
			}
			$pages[ $normalized['key'] ] = $normalized;
		}

		// Extensions append their own descriptors. Give them the core set as
		// context (array_values so filters can safely $pages[] = [...]).
		$filtered = apply_filters( 'titancart_registered_pages', array_values( $pages ) ); // = self::REGISTERED_PAGES_FILTER; literal so the prefix sniff can read it.

		if ( is_array( $filtered ) ) {
			foreach ( $filtered as $descriptor ) {
				if ( ! is_array( $descriptor ) ) {
					continue;
				}
				$normalized = self::normalize( $descriptor );
				if ( '' === $normalized['key'] ) {
					continue;
				}
				// A core-owned key cannot be redefined by the filter.
				if ( isset( $pages[ $normalized['key'] ] ) && 'core' === $pages[ $normalized['key'] ]['owner'] ) {
					continue;
				}
				$pages[ $normalized['key'] ] = $normalized;
			}
		}

		return array_values( $pages );
	}

	/**
	 * Fill defaults and coerce a raw descriptor into the canonical shape.
	 *
	 * @param  array<string,mixed> $descriptor
	 * @return array<string,string>
	 */
	public static function normalize( array $descriptor ): array {
		$key = isset( $descriptor['key'] ) ? sanitize_key( (string) $descriptor['key'] ) : '';

		$type = isset( $descriptor['type'] ) ? (string) $descriptor['type'] : 'shortcode';
		if ( ! in_array( $type, self::TYPES, true ) ) {
			$type = 'shortcode';
		}

		$title = isset( $descriptor['title'] ) ? trim( (string) $descriptor['title'] ) : '';
		$fallback_title = '' !== $key ? ucwords( str_replace( [ '_', '-' ], ' ', $key ) ) : '';
		if ( '' === $title ) {
			$title = $fallback_title;
		}

		$slug = isset( $descriptor['slug'] ) && '' !== $descriptor['slug']
			? sanitize_title( (string) $descriptor['slug'] )
			: ( '' !== $key ? sanitize_title( $key ) : '' );

		$label = isset( $descriptor['label'] ) && '' !== trim( (string) $descriptor['label'] )
			? (string) $descriptor['label']
			: ( '' !== $title ? $title : $fallback_title );

		$owner = isset( $descriptor['owner'] ) && '' !== $descriptor['owner']
			? (string) $descriptor['owner']
			: 'extension';

		return [
			'key'         => $key,
			'title'       => $title,
			'slug'        => $slug,
			'shortcode'   => isset( $descriptor['shortcode'] ) ? (string) $descriptor['shortcode'] : '',
			'option'      => isset( $descriptor['option'] ) ? (string) $descriptor['option'] : '',
			'owner'       => $owner,
			'type'        => $type,
			'label'       => $label,
			'description' => isset( $descriptor['description'] ) ? (string) $descriptor['description'] : '',
		];
	}

	/**
	 * Look up a single normalized descriptor by key.
	 *
	 * @return array<string,string>|null
	 */
	public static function get( string $key ): ?array {
		$key = sanitize_key( $key );
		if ( '' === $key ) {
			return null;
		}
		foreach ( self::all() as $descriptor ) {
			if ( $descriptor['key'] === $key ) {
				return $descriptor;
			}
		}
		return null;
	}

	/**
	 * Whether a descriptor backs a real WP page the registry can create.
	 * Rewrite (virtual) pages are never created.
	 *
	 * @param array<string,string> $descriptor
	 */
	public static function is_creatable( array $descriptor ): bool {
		$type = $descriptor['type'] ?? 'shortcode';
		return 'structural' === $type || 'shortcode' === $type;
	}

	// ══════════════════════════════════════════════════════════════════════════
	// WordPress hosting layer (isolated — see PORTABILITY note above)
	// ══════════════════════════════════════════════════════════════════════════

	/**
	 * Resolve the WP page ID currently hosting a registry page.
	 *
	 * Resolution order (mirrors the historical idempotency + the gift-cards
	 * module's own option→shortcode fallback):
	 *   1. The stored titancart_page_* option, if it points at a live page.
	 *   2. A published page whose content contains the shortcode (a recreated
	 *      page under any slug).
	 *   3. A published page at the default slug.
	 *
	 * @param  array<string,string>|string $descriptor A descriptor or a registry key.
	 * @return int The hosting page ID, or 0 if none exists.
	 */
	public static function resolve_page_id( $descriptor ): int {
		$d = is_array( $descriptor ) ? self::normalize( $descriptor ) : self::get( (string) $descriptor );
		if ( ! $d ) {
			return 0;
		}

		// 0) Structural pages (homepage/blog) are bound through the WordPress
		//    reading settings — page_on_front / page_for_posts — NOT a
		//    titancart_page_* option or a slug. Resolve them from there so the
		//    Page Manager reflects the real front-page / posts-page choice and
		//    never reports "Missing" for a page that IS set in Settings → Reading.
		if ( 'structural' === $d['type'] ) {
			if ( 'blog' === $d['key'] ) {
				$id = (int) get_option( 'page_for_posts' );
			} else {
				// Homepage only resolves when a static page is actually the front
				// page (show_on_front = 'page'); otherwise the front is the blog
				// listing and there is no hosting page.
				$id = ( 'page' === get_option( 'show_on_front' ) ) ? (int) get_option( 'page_on_front' ) : 0;
			}
			if ( $id > 0 ) {
				$status = self::page_status( $id );
				if ( 'missing' !== $status && 'trash' !== $status ) {
					return $id;
				}
			}
			// Defensive fallback: a published page at the default slug (covers a
			// freshly-created homepage/blog page before it is bound in Reading).
			if ( '' !== $d['slug'] ) {
				$page = get_page_by_path( $d['slug'], OBJECT, 'page' );
				if ( $page && 'publish' === $page->post_status ) {
					return (int) $page->ID;
				}
			}
			return 0;
		}

		// 1) Canonical binding — the stored option ID (must be a live, non-trashed page).
		if ( '' !== $d['option'] ) {
			$id = (int) get_option( $d['option'] );
			if ( $id > 0 ) {
				$status = self::page_status( $id );
				if ( 'missing' !== $status && 'trash' !== $status ) {
					return $id;
				}
			}
		}

		// 2) Shortcode-content match (page may have been recreated under any slug).
		if ( '' !== $d['shortcode'] ) {
			global $wpdb;
			$id = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'page' AND post_status = 'publish' AND post_content LIKE %s LIMIT 1",
					'%' . $wpdb->esc_like( $d['shortcode'] ) . '%'
				)
			);
			if ( $id > 0 ) {
				return $id;
			}
		}

		// 3) Slug lookup.
		if ( '' !== $d['slug'] ) {
			$page = get_page_by_path( $d['slug'], OBJECT, 'page' );
			if ( $page && 'publish' === $page->post_status ) {
				return (int) $page->ID;
			}
		}

		return 0;
	}

	/**
	 * The status of a WP page: publish|draft|pending|private|trash, or 'missing'
	 * when the ID does not resolve to a page.
	 */
	public static function page_status( int $page_id ): string {
		if ( $page_id <= 0 ) {
			return 'missing';
		}
		$post = get_post( $page_id );
		if ( ! $post || 'page' !== $post->post_type ) {
			return 'missing';
		}
		return (string) $post->post_status;
	}

	/**
	 * Idempotently ensure a creatable registry page exists and its option is
	 * bound. This is the ONE implementation that Migrations 23 & 34 previously
	 * hand-rolled per feature.
	 *
	 * - If the page already resolves (option → shortcode → slug), it is ADOPTED:
	 *   the option is (re)bound to the existing ID and no page is created.
	 * - Otherwise a published page is created with the shortcode as its content,
	 *   and the option is bound to the new ID.
	 *
	 * Rewrite (virtual) pages are skipped and return 0.
	 *
	 * @param  array<string,mixed> $descriptor
	 * @return int The hosting page ID, or 0 on skip/failure.
	 */
	public static function ensure( array $descriptor ): int {
		$d = self::normalize( $descriptor );
		if ( '' === $d['key'] || ! self::is_creatable( $d ) ) {
			return 0;
		}

		// Already present anywhere? Adopt it — never create a duplicate.
		$existing = self::resolve_page_id( $d );
		if ( $existing > 0 ) {
			if ( '' !== $d['option'] ) {
				update_option( $d['option'], $existing, false );
			}
			return $existing;
		}

		$title = '' !== $d['title'] ? $d['title'] : ucwords( str_replace( [ '_', '-' ], ' ', $d['key'] ) );

		$page_id = wp_insert_post(
			[
				'post_title'   => $title,
				'post_name'    => $d['slug'],
				'post_content' => $d['shortcode'],
				'post_status'  => 'publish',
				'post_type'    => 'page',
				'post_author'  => 1,
			]
		);

		if ( ! $page_id || is_wp_error( $page_id ) ) {
			return 0;
		}
		$page_id = (int) $page_id;

		if ( '' !== $d['option'] ) {
			update_option( $d['option'], $page_id, false );
		}

		return $page_id;
	}

	/**
	 * Ensure every creatable registry page exists (idempotent adopt-or-create).
	 *
	 * @param  bool $bound_only When true, only ensure pages that carry a
	 *                          titancart_page_* option binding (i.e. the
	 *                          shortcode feature pages). Structural pages
	 *                          (homepage/blog) are skipped so their reading-
	 *                          settings binding — owned by Migration 23 — is
	 *                          never disturbed. Migration 61 passes true.
	 * @return array<string,int> Map of registry key => hosting page ID (0 if skipped/failed).
	 */
	public static function ensure_all( bool $bound_only = false ): array {
		$out = [];
		foreach ( self::all() as $descriptor ) {
			if ( ! self::is_creatable( $descriptor ) ) {
				continue;
			}
			if ( $bound_only && '' === $descriptor['option'] ) {
				continue;
			}
			$out[ $descriptor['key'] ] = self::ensure( $descriptor );
		}
		return $out;
	}
}
