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

/**
 * The site's navigation, whatever shape it takes.
 *
 * An Aura section draws its own header with its own markup and its own CSS: what it needs from the
 * site is not the navigation BLOCK — that would drag the theme's markup and styles into the canvas,
 * outside our design system — but its ITEMS. This class pulls them out of wherever they really
 * live, which changes from site to site:
 *
 *  - block theme (FSE, say Twenty Twenty-Five): the menu is a `wp_navigation` post whose content is
 *    block markup (navigation-link, navigation-submenu, page-list, home-link);
 *  - classic theme: the menu is a `nav_menu` term assigned to a registered location;
 *  - neither: the page list, which is also core/navigation's own fallback.
 *
 * When the template does not say which menu it wants, the resolution order is the SAME one
 * WordPress uses for a <!-- wp:navigation --> with no `ref`: the most recent block navigation
 * first, then the classic menus, then the pages. That way the generated header shows the same items
 * as the theme's real header, instead of diverging from it.
 *
 * The item shape is one and the same whatever the source — it is what the template engine already
 * exposes: field.label, field.url, field.target, field.current, field.aria_current,
 * field.has_children, field.children.
 */
class AIPB_Nav {

	/** Maximum submenu depth: past this it is not navigation any more, it is a maze. */
	const MAX_DEPTH = 3;

	/** How many pages the fallback list goes down to. */
	const PAGE_LIMIT = 30;

	/** Per-request memo: a header and a footer ask for the same menu twice. */
	protected static $memo = array();

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

	/**
	 * Adds an item to the site's menu, in the shape that site uses.
	 *
	 * WHAT IT SOLVES. Ever since Aura could create a content type with an archive, it could also
	 * create pages nobody can reach: the planets archive exists as a URL and no link on the site
	 * leads to it. This class could read a navigation and not touch it — half the job.
	 *
	 * THE TWO WORLDS are written as differently as they are read: on a block theme the item is a
	 * `core/navigation-link` inside a `wp_navigation` post's content, on a classic theme it is a
	 * `nav_menu_item`. The caller does not have to know, just as whoever reads the items does not.
	 *
	 * IT DOES NOT CREATE A MENU THAT IS NOT THERE. On a site with no navigation the theme shows the
	 * page list, and manufacturing a menu with a single item in it would change the header as a side
	 * effect: it answers 'no-menu' and tells whoever asked.
	 *
	 * @return array{ok:bool,kind:string,menu:string,reason:string}
	 */
	public static function add_item( $label, $url, $attrs = array() ) {
		$label = trim( wp_strip_all_tags( (string) $label ) );
		$url   = esc_url_raw( (string) $url );

		if ( '' === $label || '' === $url ) {
			return array( 'ok' => false, 'kind' => '', 'menu' => '', 'reason' => 'incomplete' );
		}

		$attrs   = is_array( $attrs ) ? $attrs : array();
		$target  = self::resolve( $attrs );
		$outcome = array( 'ok' => false, 'kind' => $target['kind'], 'menu' => $target['label'], 'reason' => '' );

		if ( 'pages' === $target['kind'] || ! $target['id'] ) {
			$outcome['reason'] = 'no-menu';

			return $outcome;
		}

		// Already there? The URL is compared and not the label: the same destination under a
		// different name is still a duplicate, and two items pointing at the same place is the
		// quickest way to make a menu look broken.
		if ( self::has_url( self::items( $attrs ), $url ) ) {
			$outcome['ok']     = true;
			$outcome['reason'] = 'already-there';

			return $outcome;
		}

		$done = 'block' === $target['kind']
			? self::append_to_navigation( (int) $target['id'], $label, $url )
			: self::append_to_classic( (int) $target['id'], $label, $url );

		$outcome['ok']     = (bool) $done;
		$outcome['reason'] = $done ? 'added' : 'failed';

		// The items are memoised per request: without clearing it, a second add straight after
		// would read the earlier list and believe the first one never happened.
		self::$memo = array();

		return $outcome;
	}

	/**
	 * Block theme: a `core/navigation-link` at the end of the navigation post's content.
	 *
	 * It goes through parse_blocks/serialize_blocks and not through string concatenation: that
	 * content is block markup with its delimiter comments, and sticking text on the end produces a
	 * malformed block that WordPress's editor then refuses to open.
	 */
	protected static function append_to_navigation( $post_id, $label, $url ) {
		$post = get_post( $post_id );
		if ( ! $post || 'wp_navigation' !== $post->post_type ) {
			return false;
		}

		$blocks   = parse_blocks( (string) $post->post_content );
		$blocks[] = array(
			'blockName'    => 'core/navigation-link',
			'attrs'        => array( 'label' => $label, 'url' => $url, 'kind' => 'custom' ),
			'innerBlocks'  => array(),
			'innerHTML'    => '',
			'innerContent' => array(),
		);

		$updated = wp_update_post( array(
			'ID'           => $post_id,
			'post_content' => serialize_blocks( $blocks ),
		), true );

		return ! is_wp_error( $updated ) && $updated;
	}

	/** Classic theme: a nav_menu_item at the end of the menu. */
	protected static function append_to_classic( $menu_id, $label, $url ) {
		$id = wp_update_nav_menu_item( $menu_id, 0, array(
			'menu-item-title'  => $label,
			'menu-item-url'    => $url,
			'menu-item-type'   => 'custom',
			'menu-item-status' => 'publish',
		) );

		return ! is_wp_error( $id ) && $id;
	}

	/** Is there already an item pointing there? Looks inside the submenus too. */
	protected static function has_url( $items, $url ) {
		$wanted = untrailingslashit( $url );

		foreach ( (array) $items as $item ) {
			if ( isset( $item['url'] ) && untrailingslashit( (string) $item['url'] ) === $wanted ) {
				return true;
			}
			if ( ! empty( $item['children'] ) && self::has_url( $item['children'], $url ) ) {
				return true;
			}
		}

		return false;
	}

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

	/**
	 * The menu's items, as a tree.
	 *
	 * @param array $attrs ref="12" (a wp_navigation post) | location="primary" (classic theme)
	 *                     | name="Main menu" (name or slug, classic or block)
	 *                     | fallback="pages|none" (default: pages)
	 */
	public static function items( $attrs = array() ) {
		$attrs = is_array( $attrs ) ? $attrs : array();
		$key   = md5( wp_json_encode( array(
			isset( $attrs['ref'] ) ? $attrs['ref'] : '',
			isset( $attrs['location'] ) ? $attrs['location'] : '',
			isset( $attrs['name'] ) ? $attrs['name'] : '',
			isset( $attrs['fallback'] ) ? $attrs['fallback'] : '',
			get_queried_object_id(),
		) ) );

		if ( isset( self::$memo[ $key ] ) ) {
			return self::$memo[ $key ];
		}

		$source = self::resolve( $attrs );
		$items  = array();

		if ( 'block' === $source['kind'] ) {
			$items = self::items_from_navigation_post( $source['id'] );
		} elseif ( 'classic' === $source['kind'] ) {
			$items = self::items_from_classic_menu( $source['id'] );
		}

		// An empty menu is a header with no links: the page list is better, as the core does.
		$fallback = isset( $attrs['fallback'] ) ? sanitize_key( $attrs['fallback'] ) : 'pages';
		if ( ! $items && 'none' !== $fallback ) {
			$items = self::items_from_pages( 0, 0 );
		}

		self::$memo[ $key ] = $items;

		return $items;
	}

	/**
	 * Which menu will be used.
	 *
	 * The inventory needs it too: the model has to know what it is going to get.
	 *
	 * @return array{kind:string,id:int,label:string} kind is block|classic|pages
	 */
	public static function resolve( $attrs = array() ) {
		$attrs = is_array( $attrs ) ? $attrs : array();

		// 1. An explicit reference to a block navigation.
		//    Through AIPB_WPML::object_id because the reference was written when the header was
		//    built, in one language, and the same menu exists once per language: without this the
		//    English page gets the Italian header, which is the most visible thing on the site.
		if ( ! empty( $attrs['ref'] ) ) {
			$post = get_post( AIPB_WPML::object_id( (int) $attrs['ref'] ) );
			if ( $post && 'wp_navigation' === $post->post_type ) {
				return self::source( 'block', $post->ID, $post->post_title );
			}
		}

		// 2. A registered location: the right way on classic themes, and it survives a menu being
		//    swapped. On block themes there is almost never anything assigned.
		if ( ! empty( $attrs['location'] ) ) {
			$locations = get_nav_menu_locations();
			$location  = sanitize_key( $attrs['location'] );

			if ( ! empty( $locations[ $location ] ) ) {
				$menu = wp_get_nav_menu_object( $locations[ $location ] );
				if ( $menu ) {
					return self::source( 'classic', $menu->term_id, $menu->name );
				}
			}
		}

		// 3. By name: we look in both worlds, because on a migrated site the same name often exists
		//    twice — the old classic menu and the block navigation WordPress made by converting it.
		//    The active theme's wins: that is the live one.
		if ( ! empty( $attrs['name'] ) ) {
			$found = self::resolve_by_name( (string) $attrs['name'] );
			if ( $found ) {
				return $found;
			}
		}

		// 4. No indication: the same choice <!-- wp:navigation --> makes with no ref, that is, the
		//    most recently published block navigation. It is the menu the visitor sees in the
		//    theme's header, and Aura's header has to show that one.
		$navigation = self::most_recent_navigation();
		if ( $navigation ) {
			return self::source( 'block', $navigation->ID, $navigation->post_title );
		}

		// 5. Classic theme: the first assigned location, then the menu with the most items — not
		//    the first alphabetically, which on a real site is often a utility menu.
		foreach ( (array) get_nav_menu_locations() as $menu_id ) {
			if ( $menu_id ) {
				$menu = wp_get_nav_menu_object( $menu_id );
				if ( $menu ) {
					return self::source( 'classic', $menu->term_id, $menu->name );
				}
			}
		}

		$menus = wp_get_nav_menus();
		if ( $menus && ! is_wp_error( $menus ) ) {
			usort( $menus, function ( $a, $b ) {
				return (int) $b->count - (int) $a->count;
			} );

			return self::source( 'classic', $menus[0]->term_id, $menus[0]->name );
		}

		return self::source( 'pages', 0, __( 'the page list', 'aipb' ) );
	}

	/** The block navigations that exist, most recent first. */
	public static function navigation_posts( $limit = 10 ) {
		if ( ! post_type_exists( 'wp_navigation' ) ) {
			return array();
		}

		return get_posts( array(
			'post_type'        => 'wp_navigation',
			'post_status'      => 'publish',
			'numberposts'      => max( 1, (int) $limit ),
			'orderby'          => 'post_date',
			'order'            => 'DESC',
			'suppress_filters' => false,
		) );
	}

	/** How many top-level items a source has: the inventory needs it to describe the source. */
	public static function count_for( $source ) {
		if ( ! is_array( $source ) ) {
			return 0;
		}

		if ( 'block' === $source['kind'] ) {
			return count( self::items_from_navigation_post( $source['id'] ) );
		}
		if ( 'classic' === $source['kind'] ) {
			return count( self::items_from_classic_menu( $source['id'] ) );
		}

		return count( self::items_from_pages( 0, 0 ) );
	}

	/* ------------------------------ Block navigation --------------------------- */

	/** A wp_navigation post's items: its content is block markup, so we walk it. */
	public static function items_from_navigation_post( $post_id ) {
		$post = get_post( (int) $post_id );

		if ( ! $post || 'wp_navigation' !== $post->post_type || '' === trim( (string) $post->post_content ) ) {
			return array();
		}

		return self::blocks_to_items( parse_blocks( $post->post_content ), 0 );
	}

	/**
	 * From blocks to items.
	 *
	 * The block names are the core's; any other block that has children (groups, wrappers) is
	 * walked into, the rest (logo, search, spacers) ignored: what matters here are the links, not
	 * the theme's layout.
	 */
	protected static function blocks_to_items( $blocks, $depth ) {
		if ( $depth > self::MAX_DEPTH || ! is_array( $blocks ) ) {
			return array();
		}

		$out = array();
		foreach ( $blocks as $block ) {
			$name  = isset( $block['blockName'] ) ? $block['blockName'] : '';
			$attrs = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
			$inner = isset( $block['innerBlocks'] ) ? $block['innerBlocks'] : array();

			if ( 'core/navigation-link' === $name || 'core/navigation-submenu' === $name ) {
				$children = self::blocks_to_items( $inner, $depth + 1 );
				$item     = self::make_item(
					isset( $attrs['label'] ) ? $attrs['label'] : '',
					isset( $attrs['url'] ) ? $attrs['url'] : '',
					isset( $attrs['id'] ) ? (int) $attrs['id'] : 0,
					! empty( $attrs['opensInNewTab'] ),
					isset( $attrs['description'] ) ? $attrs['description'] : '',
					$children
				);

				if ( '' !== $item['label'] || $children ) {
					$out[] = $item;
				}
				continue;
			}

			if ( 'core/home-link' === $name ) {
				$out[] = self::make_item(
					( isset( $attrs['label'] ) && '' !== $attrs['label'] ) ? $attrs['label'] : __( 'Home', 'aipb' ),
					home_url( '/' ),
					(int) get_option( 'page_on_front' ),
					false,
					'',
					array()
				);
				continue;
			}

			// The page list is a single block: we expand it into the page tree, as the core does
			// when rendering it.
			if ( 'core/page-list' === $name ) {
				$parent = isset( $attrs['parentPageID'] ) ? (int) $attrs['parentPageID'] : 0;
				$out    = array_merge( $out, self::items_from_pages( $parent, $depth ) );
				continue;
			}

			if ( 'core/page-list-item' === $name ) {
				$id = isset( $attrs['id'] ) ? (int) $attrs['id'] : 0;
				if ( $id ) {
					$out[] = self::make_item(
						isset( $attrs['label'] ) ? $attrs['label'] : get_the_title( $id ),
						(string) get_permalink( $id ),
						$id,
						false,
						'',
						self::blocks_to_items( $inner, $depth + 1 )
					);
				}
				continue;
			}

			// A wrapper (a nested core/navigation, a group): go down.
			if ( $inner ) {
				$out = array_merge( $out, self::blocks_to_items( $inner, $depth ) );
			}
		}

		return $out;
	}

	/* ------------------------------- Classic menus ----------------------------- */

	public static function items_from_classic_menu( $term_id ) {
		if ( ! function_exists( 'wp_get_nav_menu_items' ) ) {
			return array();
		}

		$items = wp_get_nav_menu_items( (int) $term_id, array( 'update_post_term_cache' => false ) );

		return $items ? self::classic_tree( $items, 0, 0 ) : array();
	}

	/** Recursion over $parent's children. Menus are small: simplicity beats optimisation. */
	protected static function classic_tree( $items, $parent, $depth ) {
		if ( $depth > self::MAX_DEPTH ) {
			return array();
		}

		$out = array();
		foreach ( $items as $entry ) {
			if ( (int) $entry->menu_item_parent !== (int) $parent ) {
				continue;
			}

			$item = self::make_item(
				$entry->title,
				$entry->url,
				(int) $entry->object_id,
				'_blank' === $entry->target,
				$entry->description,
				self::classic_tree( $items, $entry->ID, $depth + 1 )
			);

			$item['title_attr'] = $entry->attr_title;
			$item['id']         = (int) $entry->ID;

			$out[] = $item;
		}

		return $out;
	}

	/* ---------------------------- Fallback: the pages -------------------------- */

	/** $parent's child pages, in menu order, with one level of subpages. */
	public static function items_from_pages( $parent = 0, $depth = 0 ) {
		if ( $depth > self::MAX_DEPTH ) {
			return array();
		}

		$pages = get_posts( array(
			'post_type'        => 'page',
			'post_status'      => 'publish',
			'post_parent'      => (int) $parent,
			'numberposts'      => self::PAGE_LIMIT,
			'orderby'          => array( 'menu_order' => 'ASC', 'title' => 'ASC' ),
			'suppress_filters' => false,
		) );

		$out = array();
		foreach ( $pages as $page ) {
			$out[] = self::make_item(
				get_the_title( $page ),
				(string) get_permalink( $page ),
				(int) $page->ID,
				false,
				'',
				self::items_from_pages( (int) $page->ID, $depth + 1 )
			);
		}

		return $out;
	}

	/* ---------------------------------- Shared --------------------------------- */

	/** @return array{kind:string,id:int,label:string} */
	protected static function source( $kind, $id, $label ) {
		return array( 'kind' => $kind, 'id' => (int) $id, 'label' => (string) $label );
	}

	/** A menu by name, looking in the active theme's world first. */
	protected static function resolve_by_name( $name ) {
		$block_theme = function_exists( 'wp_is_block_theme' ) && wp_is_block_theme();

		if ( $block_theme ) {
			$post = self::find_navigation_by_name( $name );
			if ( $post ) {
				return self::source( 'block', $post->ID, $post->post_title );
			}
		}

		$menu = wp_get_nav_menu_object( $name );
		if ( $menu ) {
			return self::source( 'classic', $menu->term_id, $menu->name );
		}

		if ( ! $block_theme ) {
			$post = self::find_navigation_by_name( $name );
			if ( $post ) {
				return self::source( 'block', $post->ID, $post->post_title );
			}
		}

		return null;
	}

	protected static function most_recent_navigation() {
		$posts = self::navigation_posts( 1 );

		return $posts ? $posts[0] : null;
	}

	protected static function find_navigation_by_name( $name ) {
		$slug = sanitize_title( $name );

		foreach ( self::navigation_posts( 50 ) as $post ) {
			if ( $post->post_name === $slug || strtolower( $post->post_title ) === strtolower( $name ) ) {
				return $post;
			}
		}

		return null;
	}

	protected static function make_item( $label, $url, $object_id, $new_tab, $description, $children ) {
		$here = self::is_current( $url, $object_id );

		return array(
			'label'       => wp_strip_all_tags( (string) $label ),
			'url'         => (string) $url,
			'target'      => $new_tab ? '_blank' : '',
			'description' => wp_strip_all_tags( (string) $description ),
			'title_attr'  => '',
			// The page being visited right now: for marking the active item (aria-current="page").
			'current'      => $here ? '1' : '',
			'aria_current' => $here ? 'page' : '',
			'has_children' => $children ? '1' : '',
			'children'     => $children,
			'id'           => (int) $object_id,
		);
	}

	/**
	 * Does the item point at the content being looked at? By id first — the only reliable
	 * comparison — then by URL, which covers hand-written links to an internal page.
	 */
	protected static function is_current( $url, $object_id ) {
		$queried = (int) get_queried_object_id();
		if ( ! $queried ) {
			return false;
		}

		if ( $object_id && (int) $object_id === $queried ) {
			return true;
		}
		if ( '' === (string) $url ) {
			return false;
		}

		$permalink = get_permalink( $queried );
		if ( ! $permalink ) {
			return false;
		}

		return untrailingslashit( strtok( (string) $url, '?' ) ) === untrailingslashit( strtok( $permalink, '?' ) );
	}
}
