<?php
namespace AIOSEO\Plugin\Common\Traits\Helpers;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use AIOSEO\Plugin\Common\Integrations\BuddyPress as BuddyPressIntegration;

/**
 * Contains all WordPress related URL, URI, path, slug, etc. related helper methods.
 *
 * @since 4.1.4
 */
trait WpUri {
	/**
	 * Returns the site domain.
	 *
	 * @since 4.0.0
	 *
	 * @param  bool   $unfiltered Whether to get the unfiltered value.
	 * @return string             The site's domain.
	 */
	public function getSiteDomain( $unfiltered = false ) {
		return wp_parse_url( $this->getHomeUrl( $unfiltered ), PHP_URL_HOST );
	}

	/**
	 * Returns the site URL.
	 * NOTE: For multisites inside a sub-directory, this returns the URL for the main site.
	 * This is intentional.
	 *
	 * @since 4.0.0
	 *
	 * @param  bool   $unfiltered Whether to get the unfiltered value.
	 * @return string             The site's domain.
	 */
	public function getSiteUrl( $unfiltered = false ) {
		$homeUrl = $this->getHomeUrl( $unfiltered );

		return wp_parse_url( $homeUrl, PHP_URL_SCHEME ) . '://' . wp_parse_url( $homeUrl, PHP_URL_HOST );
	}

	/**
	 * Returns the current URL.
	 *
	 * @since 4.0.0
	 *
	 * @param  boolean $canonical Whether or not to get the canonical URL.
	 * @return string             The URL.
	 */
	public function getUrl( $canonical = false ) {
		$url = '';
		if ( is_singular() ) {
			$objectId = aioseo()->helpers->getPostId();

			if ( $canonical ) {
				$url = aioseo()->helpers->wpGetCanonicalUrl( $objectId );
			}

			if ( ! $url ) {
				// wp_get_canonical_url() returns false if the post isn't published.
				// Therefore, we must to fall back to the permalink if the post isn't published, e.g. draft post or attachment (inherit).
				$url = get_permalink( $objectId );
			}
		}

		if ( $url ) {
			return $url;
		}

		global $wp;
		// Permalink url without the query string.
		$url = user_trailingslashit( home_url( $wp->request ) );

		// If permalinks are not being used we need to append the query string to the home url.
		if ( ! $this->usingPermalinks() ) {
			$url = home_url( ! empty( $wp->query_string ) ? '?' . $wp->query_string : '' );
		}

		return $url;
	}

	/**
	 * Gets the canonical URL for the current page/post.
	 *
	 * @since 4.0.0
	 *
	 * @return string $url The canonical URL.
	 */
	public function canonicalUrl() {
		$queriedObject = get_queried_object(); // Don't use our getTerm helper here.
		$hash          = md5( wp_json_encode( $queriedObject ?? [] ) );

		static $url = [];
		if ( isset( $url[ $hash ] ) ) {
			return $url[ $hash ];
		}

		if ( is_404() || is_search() ) {
			$url[ $hash ] = apply_filters( 'aioseo_canonical_url', '' );

			return $url[ $hash ];
		}

		$metaData = [];
		$post     = $this->getPost();
		if ( $post ) {
			$metaData = aioseo()->meta->metaData->getMetaData( $post );
		}

		if ( is_category() || is_tag() || is_tax() ) {
			$metaData     = aioseo()->meta->metaData->getMetaData( $queriedObject );
			$url[ $hash ] = get_term_link( $queriedObject, $queriedObject->taxonomy ?? '' );

			// If the term link is a WP_Error, set it to an empty string.
			if ( ! is_string( $url[ $hash ] ) ) {
				$url[ $hash ] = '';
			}

			// Add pagination to the URL. We need to do this here because get_term_link() doesn't handle pagination.
			// We'll strip it further down if no pagination for canonical is enabled.
			if ( $this->getPageNumber() > 1 ) {
				$url[ $hash ] = user_trailingslashit( rtrim( $url[ $hash ], '/' ) . '/page/' . $this->getPageNumber() );
			}
		}

		if ( $metaData && ! empty( $metaData->canonical_url ) ) {
			$url[ $hash ] = apply_filters( 'aioseo_canonical_url', $this->makeUrlAbsolute( $metaData->canonical_url ) );

			return $url[ $hash ];
		}

		if ( BuddyPressIntegration::isComponentPage() ) {
			$url[ $hash ] = aioseo()->standalone->buddyPress->component->getMeta( 'canonical' );
		}

		if ( empty( $url[ $hash ] ) || is_wp_error( $url[ $hash ] ) ) {
			$url[ $hash ] = $this->getUrl( true );
		}

		$pageNumber = $this->getPageNumber();
		if (
			in_array( 'noPaginationForCanonical', aioseo()->internalOptions->deprecatedOptions, true ) &&
			aioseo()->options->deprecated->searchAppearance->advanced->noPaginationForCanonical
		) {
			if ( 1 < $pageNumber ) {
				if ( $this->usingPermalinks() ) {
					// Replace /page/3 and /page/3/.
					$url[ $hash ] = preg_replace( "@(?<=/)page/$pageNumber(/|)$@", '', (string) $url[ $hash ] );
					// Replace /3 and /3/.
					$url[ $hash ] = preg_replace( "@(?<=/)$pageNumber(/|)$@", '', (string) $url[ $hash ] );
				} else {
					// Replace /?page_id=457&paged=1 and /?page_id=457&page=1.
					$url[ $hash ] = aioseo()->helpers->urlRemoveQueryParameter( $url[ $hash ], [ 'page', 'paged' ] );
				}
			}

			// Comment pages.
			$url[ $hash ] = preg_replace( '/(?<=\/)comment-page-\d+\/*(#comments)*$/', '', (string) $url[ $hash ] );
		}

		$url[ $hash ] = $this->maybeRemoveTrailingSlash( $url[ $hash ] );

		// Get rid of /amp at the end of the URL.
		if (
			aioseo()->helpers->isAmpPage() &&
			! apply_filters( 'aioseo_disable_canonical_url_amp', false )
		) {
			$url[ $hash ] = preg_replace( '/\/amp$/', '', (string) $url[ $hash ] );
			$url[ $hash ] = preg_replace( '/\/amp\/$/', '/', (string) $url[ $hash ] );
		}

		$url[ $hash ] = apply_filters( 'aioseo_canonical_url', $url[ $hash ] );

		return $url[ $hash ];
	}

	/**
	 * Sanitizes a given domain.
	 *
	 * @since 4.0.0
	 *
	 * @param  string       $domain The domain to sanitize.
	 * @return mixed|string         The sanitized domain.
	 */
	public function sanitizeDomain( $domain ) {
		$domain = trim( $domain );
		$domain = strtolower( $domain );
		if ( 0 === strpos( $domain, 'http://' ) ) {
			$domain = substr( $domain, 7 );
		} elseif ( 0 === strpos( $domain, 'https://' ) ) {
			$domain = substr( $domain, 8 );
		}
		$domain = untrailingslashit( $domain );

		return $domain;
	}

	/**
	 * Remove trailing slashes if not set in the permalink structure.
	 *
	 * @since 4.0.0
	 *
	 * @param  string $url The original URL.
	 * @return string      The adjusted URL.
	 */
	public function maybeRemoveTrailingSlash( $url ) {
		$permalinks = get_option( 'permalink_structure' );
		if ( $permalinks && ( ! is_home() || ! is_front_page() ) ) {
			$trailing = substr( $permalinks, -1 );
			if ( '/' !== $trailing ) {
				$url = untrailingslashit( $url );
			}
		}

		// Don't slash urls with query args.
		if ( false !== strpos( $url, '?' ) ) {
			$url = untrailingslashit( $url );
		}

		return $url;
	}

	/**
	 * Removes image dimensions from the slug of a URL.
	 *
	 * @since 4.0.0
	 *
	 * @param  string $url The image URL.
	 * @return string      The formatted image URL.
	 */
	public function removeImageDimensions( $url ) {
		return $this->isValidAttachment( $url ) ? preg_replace( '#(-[0-9]*x[0-9]*|-scaled)#', '', (string) $url ) : $url;
	}

	/**
	 * Returns the URL for the WP content folder.
	 *
	 * @since 4.0.5
	 *
	 * @return string The URL.
	 */
	public function getWpContentUrl() {
		$info = wp_get_upload_dir();

		return isset( $info['baseurl'] ) ? $info['baseurl'] : '';
	}

	/**
	* Retrieves a post by its given path.
	* Based on the built-in get_page_by_path() function, but only checks ancestry if the post type is actually hierarchical.
	*
	* @since   4.1.4
	* @version 4.9.9 Validate URL prefix against the post type's rewrite slug.
	*
	* @param  string       $path     The path.
	* @param  string       $output   The output type. OBJECT, ARRAY_A, or ARRAY_N.
	* @param  string|array $postType The post type(s) to check against.
	* @return object|false           The post or false on failure.
	*/
	public function getPostByPath( $path, $output = OBJECT, $postType = null ) {
		// If no post type specified, use all public post types
		if ( null === $postType ) {
			$postType = $this->getPublicPostTypes( true );
		}

		$lastChanged = wp_cache_get_last_changed( 'aioseo_posts_by_path' );
		$hash        = md5( $path . serialize( $postType ) );
		$cacheKey    = "get_page_by_path:$hash:$lastChanged";
		$cached      = wp_cache_get( $cacheKey, 'aioseo_posts_by_path' );

		if ( false !== $cached ) {
			// Special case: '0' is a bad `$path`.
			if ( '0' === $cached || 0 === $cached ) {
				return false;
			}

			return get_post( $cached, $output );
		}

		$path          = rawurlencode( urldecode( $path ) );
		$path          = str_replace( '%2F', '/', $path );
		$path          = str_replace( '%20', ' ', $path );
		$parts         = explode( '/', trim( $path, '/' ) );
		$reversedParts = array_reverse( $parts );

		$postTypes = is_array( $postType ) ? $postType : [ $postType ];

		$posts = aioseo()->core->db->start( 'posts' )
			->select( 'ID, post_name, post_parent, post_type' )
			->whereIn( 'post_name', $parts )
			->whereIn( 'post_type', $postTypes )
			->whereIn( 'post_status', [ 'publish' ] )
			->run()
			->result();

		if ( empty( $posts ) ) {
			wp_cache_set( $cacheKey, 0, 'aioseo_posts_by_path' );

			return false;
		}

		// Create a lookup array for posts by ID for efficient parent lookups
		$postsById = [];
		foreach ( $posts as $post ) {
			$postsById[ $post->ID ] = $post;
		}

		$foundId = 0;
		$targetPostTypes = is_array( $postType ) ? $postType : [ $postType ];

		foreach ( $posts as $post ) {
			if ( $post->post_name === $reversedParts[0] ) {
				$count = 0;
				$p     = $post;

				// Loop through the given path parts from right to left, ensuring each matches the post ancestry.
				while ( 0 !== (int) $p->post_parent && isset( $postsById[ $p->post_parent ] ) ) {
					$count++;
					$parent = $postsById[ $p->post_parent ];
					if ( ! isset( $reversedParts[ $count ] ) || $parent->post_name !== $reversedParts[ $count ] ) {
						break;
					}
					$p = $parent;
				}

				if (
					0 === (int) $p->post_parent &&
					$this->urlPathMatchesPostType( $p->post_type, $reversedParts, $count ) &&
					$p->post_name === $reversedParts[ $count ]
				) {
					$foundId = $post->ID;

					// If we're looking for specific post types, prefer exact matches
					if ( ! is_array( $postType ) && $post->post_type === $postType ) {
						break;
					}
					// If we're looking for multiple post types, any match is good
					if ( is_array( $postType ) && in_array( $post->post_type, $targetPostTypes, true ) ) {
						break;
					}
				}
			}
		}

		// We cache misses as well as hits.
		wp_cache_set( $cacheKey, $foundId, 'aioseo_posts_by_path' );

		return $foundId ? get_post( $foundId, $output ) : false;
	}

	/**
	 * Checks that the URL prefix preceding the matched slug is compatible with the candidate's post type.
	 *
	 * @since 4.9.9
	 *
	 * @param  string $postType      The candidate's post type.
	 * @param  array  $reversedParts The path segments in reverse order.
	 * @param  int    $count         The ancestry depth already consumed.
	 * @return bool                  True if the URL path is compatible with the post type.
	 */
	private function urlPathMatchesPostType( $postType, $reversedParts, $count ) {
		static $expectedPrefixes = [];

		if ( ! array_key_exists( $postType, $expectedPrefixes ) ) {
			$expectedPrefixes[ $postType ] = $this->getPostTypeUrlPrefix( $postType );
		}

		$expectedPrefix = $expectedPrefixes[ $postType ];

		// No rewrite registration (e.g. built-in `post`/`page`): keep historical behavior.
		if ( null === $expectedPrefix ) {
			return is_post_type_hierarchical( $postType )
				? count( $reversedParts ) === $count + 1
				: true;
		}

		$actualPrefix = implode( '/', array_reverse( array_slice( $reversedParts, $count + 1 ) ) );

		return $actualPrefix === $expectedPrefix;
	}

	/**
	 * Validates a URL.
	 *
	 * @since 4.1.2
	 *
	 * @param  string $url The url.
	 * @return bool        Is it a valid/safe url.
	 */
	public function isUrl( $url ) {
		return esc_url_raw( $url ) === $url;
	}

	/**
	 * Retrieves the parameters for a given URL.
	 *
	 * @since 4.1.5
	 *
	 * @param  string $url          The url.
	 * @return array                The parameters.
	 */
	public function getParametersFromUrl( $url ) {
		$parsedUrl  = wp_parse_url( wp_unslash( $url ) );
		$parameters = [];

		if ( empty( $parsedUrl['query'] ) ) {
			return [];
		}

		wp_parse_str( $parsedUrl['query'], $parameters );

		return $parameters;
	}

	/**
	 * Adds a leading slash to an url.
	 *
	 * @since 4.1.8
	 *
	 * @param  string $url The url.
	 * @return string      The url with a leading slash.
	 */
	public function leadingSlashIt( $url ) {
		return '/' . ltrim( $url, '/' );
	}

	/**
	 * Returns the path from a permalink.
	 * This function will help get the correct path from WP installations in subfolders.
	 *
	 * @since 4.1.8
	 *
	 * @param  string $permalink A permalink from get_permalink().
	 * @return string            The path without the home_url().
	 */
	public function getPermalinkPath( $permalink ) {
		// We want to get this value straight from the DB to prevent plugins like WPML from filtering it.
		// This will otherwise mess with things like license activation requests and redirects.
		$homeUrl = $this->getHomeUrl( true );

		return $this->leadingSlashIt( str_replace( $homeUrl, '', $permalink ) );
	}

	/**
	 * Changed if permalinks are different and the before wasn't
	 * the site url (we don't want to redirect the site URL).
	 *
	 * @since 4.2.3
	 *
	 * @param  string  $before The URL before the change.
	 * @param  string  $after  The URL after the change.
	 * @return boolean         True if the permalink has changed.
	 */
	public function hasPermalinkChanged( $before, $after ) {
		// Check it's not redirecting from the root.
		if ( $this->getHomePath() === $before || '/' === $before ) {
			return false;
		}

		// Are the URLs the same?
		return ( $before !== $after );
	}

	/**
	 * Retrieve the home path.
	 *
	 * @since 4.2.3
	 *
	 * @param  bool   $unfiltered Whether to get the unfiltered value.
	 * @return string              The home path.
	 */
	public function getHomePath( $unfiltered = false ) {
		$path = wp_parse_url( $this->getHomeUrl( $unfiltered ), PHP_URL_PATH );

		return $path ? trailingslashit( $path ) : '/';
	}

	/**
	 * Returns the home URL.
	 *
	 * @since 4.7.3
	 *
	 * @param  bool   $unfiltered Whether to get the unfiltered value.
	 * @return string             The home URL.
	 */
	private function getHomeUrl( $unfiltered = false ) {
		$homeUrl = home_url();
		if ( $unfiltered ) {
			// We want to get this value straight from the DB to prevent plugins like WPML from filtering it.
			// This will otherwise mess with things like license activation requests and redirects.
			$homeUrl = get_option( 'home' );
		}

		return $homeUrl;
	}

	/**
	 * Checks if the given URL is an internal URL for the current site.
	 *
	 * @since 4.2.6
	 *
	 * @param  string $urlToCheck The URL to check.
	 * @return bool               Whether the given URL is an internal one.
	 */
	public function isInternalUrl( $urlToCheck ) {
		$parsedHomeUrl    = wp_parse_url( home_url() );
		$parsedUrlToCheck = wp_parse_url( $urlToCheck );

		return ! empty( $parsedHomeUrl['host'] ) && ! empty( $parsedUrlToCheck['host'] )
			? $parsedHomeUrl['host'] === $parsedUrlToCheck['host']
			: false;
	}

	/**
	 * Helper for the rest url.
	 *
	 * @since 4.4.9
	 *
	 * @return string
	 */
	public function getRestUrl() {
		$restUrl = get_rest_url();

		if ( aioseo()->helpers->isWpmlActive() ) {
			global $sitepress;

			// Replace the rest url 'all' language prefix so our rest calls don't fail.
			if (
				is_object( $sitepress ) &&
				method_exists( $sitepress, 'get_current_language' ) &&
				method_exists( $sitepress, 'get_default_language' ) &&
				'all' === $sitepress->get_current_language()
			) {
				$restUrl = str_replace(
					get_home_url( null, '/all/' ),
					get_home_url( null, '/' . $sitepress->get_default_language() . '/' ),
					$restUrl
				);
			}
		}

		return $restUrl;
	}

	/**
	 * Exclude the home path from a full path.
	 *
	 * @since   1.2.3 Moved from aioseo-redirects.
	 * @version 4.5.8
	 *
	 * @param  string $path The original path.
	 * @return string       The path without WP's home path.
	 */
	public function excludeHomePath( $path ) {
		return preg_replace( '@^' . $this->getHomePath() . '@', '/', (string) $path );
	}

	/**
	 * Get the canonical URL for a post.
	 * This is a duplicate of wp_get_canonical_url() with a fix for issue #6372 where
	 * posts with paginated comment pages return the wrong canonical URL due to how WordPress sets the cpage var.
	 * We can remove this once trac ticket 60806 is resolved.
	 *
	 * @since 4.6.9
	 *
	 * @param  \WP_Post|int|null $post The post object or ID.
	 * @return string|false            The post's canonical URL, or false if the post is not published.
	 */
	public function wpGetCanonicalUrl( $post = null ) {
		$post = get_post( $post );

		if ( ! $post ) {
			return false;
		}

		if ( 'publish' !== $post->post_status ) {
			return false;
		}

		$canonicalUrl = get_permalink( $post );

		// If a canonical is being generated for the current page, make sure it has pagination if needed.
		if ( get_queried_object_id() === $post->ID ) {
			$page = get_query_var( 'page', 0 );
			if ( $page >= 2 ) {
				if ( ! get_option( 'permalink_structure' ) ) {
					$canonicalUrl = add_query_arg( 'page', $page, $canonicalUrl );
				} else {
					$canonicalUrl = trailingslashit( $canonicalUrl ) . user_trailingslashit( $page, 'single_paged' );
				}
			}

			$cpage = aioseo()->helpers->getCommentPageNumber(); // We're calling our own function here to get the correct cpage number.
			if ( $cpage ) {
				$canonicalUrl = get_comments_pagenum_link( $cpage );
			}
		}

		return apply_filters( 'get_canonical_url', $canonicalUrl, $post ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
	}

	/**
	 * Checks if permalinks are enabled.
	 *
	 * @since 4.8.3
	 *
	 * @return bool Whether permalinks are enabled.
	 */
	public function usingPermalinks() {
		global $wp_rewrite; // phpcs:ignore Squiz.NamingConventions.ValidVariableName

		return $wp_rewrite->using_permalinks(); // phpcs:ignore Squiz.NamingConventions.ValidVariableName
	}
}