<?php
namespace AIOSEO\Plugin\Common\Sitemap\Html;

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

/**
 * Handles the output of the HTML sitemap.
 *
 * @since 4.1.3
 */
class Frontend {
	/**
	 * Instance of Query class.
	 *
	 * @since 4.1.3
	 *
	 * @var Query
	 */
	public $query;

	/**
	 * The attributes for the block/widget/shortcode.
	 *
	 * @since 4.1.3
	 *
	 * @var array
	 */
	private $attributes = [];

	/**
	 * Class constructor.
	 *
	 * @since 4.1.3
	 */
	public function __construct() {
		$this->query = new Query;
	}

	/**
	 * Returns the attributes.
	 *
	 * @since 4.1.3
	 *
	 * @param  array $attributes The user-defined attributes
	 * @return array             The defaults with user-defined attributes merged.
	 */
	public function getAttributes( $attributes = [] ) {
		aioseo()->sitemap->type = 'html';

		$defaults = [
			'label_tag'        => 'h4',
			'show_label'       => true,
			'order'            => aioseo()->options->sitemap->html->sortDirection,
			'order_by'         => aioseo()->options->sitemap->html->sortOrder,
			'nofollow_links'   => false,
			'publication_date' => aioseo()->options->sitemap->html->publicationDate,
			'archives'         => aioseo()->options->sitemap->html->compactArchives,
			'post_types'       => aioseo()->sitemap->helpers->includedPostTypes(),
			'taxonomies'       => aioseo()->sitemap->helpers->includedTaxonomies(),
			'excluded_posts'   => [],
			'excluded_terms'   => [],
			'is_admin'         => false
		];

		$attributes                   = shortcode_atts( $defaults, $attributes );
		$attributes['show_label']     = filter_var( $attributes['show_label'], FILTER_VALIDATE_BOOLEAN );
		$attributes['nofollow_links'] = filter_var( $attributes['nofollow_links'], FILTER_VALIDATE_BOOLEAN );
		$attributes['is_admin']       = filter_var( $attributes['is_admin'], FILTER_VALIDATE_BOOLEAN );

		return $attributes;
	}

	/**
	 * Formats the publish date according to what's set under Settings > General.
	 *
	 * @since 4.1.3
	 *
	 * @param  string $date The date that should be formatted.
	 * @return string       The formatted date.
	 */
	private function formatDate( $date ) {
		$dateFormat = apply_filters( 'aioseo_html_sitemap_date_format', get_option( 'date_format' ) );

		return date_i18n( $dateFormat, strtotime( $date ) );
	}

	/**
	 * Returns the posts of a given post type that should be included.
	 *
	 * @since 4.1.3
	 *
	 * @param  string $postType       The post type.
	 * @param  array  $additionalArgs Additional arguments for the post query (optional).
	 * @return array                  The post entries.
	 */
	private function posts( $postType, $additionalArgs = [] ) {
		$posts = $this->query->posts( $postType, $additionalArgs );
		if ( ! $posts ) {
			return [];
		}

		$entries = [];
		foreach ( $posts as $post ) {
			$entry = [
				'id'     => $post->ID,
				'title'  => get_the_title( $post ),
				'loc'    => get_permalink( $post->ID ),
				'date'   => $this->formatDate( $post->post_date_gmt ),
				'parent' => ! empty( $post->post_parent ) ? $post->post_parent : null
			];

			$entries[] = $entry;
		}

		return apply_filters( 'aioseo_html_sitemap_posts', $entries, $postType );
	}

	/**
	 * Returns the terms of a given taxonomy that should be included.
	 *
	 * @since 4.1.3
	 *
	 * @param  string $taxonomy        The taxonomy name.
	 * @param  array  $additionalArgs  Additional arguments for the query (optional).
	 * @return array                   The term entries.
	 */
	private function terms( $taxonomy, $additionalArgs = [] ) {
		$terms = $this->query->terms( $taxonomy, $additionalArgs );
		if ( ! $terms ) {
			return [];
		}

		$entries = [];
		foreach ( $terms as $term ) {
			$entries[] = [
				'id'     => $term->term_id,
				'title'  => $term->name,
				'loc'    => get_term_link( $term->term_id ),
				'parent' => ! empty( $term->parent ) ? $term->parent : null
			];
		}

		return apply_filters( 'aioseo_html_sitemap_terms', $entries, $taxonomy );
	}

	/**
	 * Outputs the sitemap to the frontend.
	 *
	 * @since 4.1.3
	 *
	 * @param  bool  $echo       Whether the sitemap should be printed to the screen.
	 * @param  array $attributes The shortcode attributes.
	 * @return string|void       The HTML sitemap.
	 */
	public function output( $echo = true, $attributes = [] ) {
		$this->attributes = $attributes;

		if ( ! aioseo()->options->sitemap->html->enable ) {
			return;
		}

		aioseo()->sitemap->type = 'html';
		if ( filter_var( $attributes['archives'], FILTER_VALIDATE_BOOLEAN ) ) {
			return ( new CompactArchive() )->output( $attributes, $echo );
		}

		if ( ! empty( $attributes['default'] ) ) {
			$attributes = $this->getAttributes();
		}

		$noResultsMessage = esc_html__( 'No posts/terms could be found.', 'all-in-one-seo-pack' );
		if ( empty( $this->attributes['post_types'] ) && empty( $this->attributes['taxonomies'] ) ) {
			if ( $echo ) {
				echo $noResultsMessage; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			}

			return $noResultsMessage;
		}

		// TODO: Consider moving all remaining HTML code below to a dedicated view instead of printing it in PHP.
		$sitemap = sprintf(
			'<div class="aioseo-html-sitemap%s">',
			! $this->attributes['show_label'] ? ' labels-hidden' : ''
		);

		$sitemap .= '<style>.aioseo-html-sitemap.labels-hidden ul { margin: 0; }</style>';

		$hasPosts  = false;
		$postTypes = $this->getIncludedObjects( $this->attributes['post_types'] );
		foreach ( $postTypes as $postType ) {
			if ( 'attachment' === $postType ) {
				continue;
			}

			// Check if post type is still registered.
			if ( ! in_array( $postType, aioseo()->helpers->getPublicPostTypes( true ), true ) ) {
				continue;
			}

			$posts = $this->posts( $postType, $attributes );
			if ( empty( $posts ) ) {
				continue;
			}

			$hasPosts = true;

			$postTypeObject = get_post_type_object( $postType );
			$label          = ! empty( $postTypeObject->label ) ? $postTypeObject->label : ucfirst( $postType );

			$sitemap .= '<div class="aioseo-html-' . esc_attr( $postType ) . '-sitemap">';
			$sitemap .= $this->generateLabel( $label );

			if ( is_post_type_hierarchical( $postType ) ) {
				$sitemap .= $this->generateHierarchicalList( $posts ) . '</div>';
				if ( $this->attributes['show_label'] ) {
					$sitemap .= '<br />';
				}
				continue;
			}

			$sitemap .= $this->generateList( $posts );
			if ( $this->attributes['show_label'] ) {
				$sitemap .= '<br />';
			}
		}

		$hasTerms   = false;
		$taxonomies = $this->getIncludedObjects( $this->attributes['taxonomies'], false );
		foreach ( $taxonomies as $taxonomy ) {
			// Check if post type is still registered.
			if ( ! in_array( $taxonomy, aioseo()->helpers->getPublicTaxonomies( true ), true ) ) {
				continue;
			}

			$terms = $this->terms( $taxonomy, $attributes );
			if ( empty( $terms ) ) {
				continue;
			}

			$hasTerms = true;

			$taxonomyObject = get_taxonomy( $taxonomy );
			$label          = ! empty( $taxonomyObject->label ) ? $taxonomyObject->label : ucfirst( $taxonomy );

			$sitemap .= '<div class="aioseo-html-' . esc_attr( $taxonomy ) . '-sitemap">';
			$sitemap .= $this->generateLabel( $label );

			if ( is_taxonomy_hierarchical( $taxonomy ) ) {
				$sitemap .= $this->generateHierarchicalList( $terms ) . '</div>';
				if ( $this->attributes['show_label'] ) {
					$sitemap .= '<br />';
				}
				continue;
			}

			$sitemap .= $this->generateList( $terms );
			if ( $this->attributes['show_label'] ) {
				$sitemap .= '<br />';
			}
		}

		$sitemap .= '</div>';

		// Check if we actually were able to fetch any results.
		if ( ! $hasPosts && ! $hasTerms ) {
			$sitemap = $noResultsMessage;
		}

		if ( $echo ) {
			echo $sitemap; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}

		return $sitemap;
	}

	/**
	 * Generates the label for a section of the sitemap.
	 *
	 * @since 4.1.3
	 *
	 * @param  string $label The label.
	 * @return string        The HTML code for the label.
	 */
	private function generateLabel( $label ) {
		$labelTag = ! empty( $this->attributes['label_tag'] ) ? $this->attributes['label_tag'] : 'h4';

		return $this->attributes['show_label']
			? sprintf( '<%2$s>%1$s</%2$s>', esc_attr( $label ), wp_kses_post( $labelTag ) )
			: '';
	}

	/**
	 * Generates the HTML for a non-hierarchical list of objects.
	 *
	 * @since 4.1.3
	 *
	 * @param  array  $objects The object.
	 * @return string          The HTML code.
	 */
	private function generateList( $objects ) {
		$list = '<ul>';
		foreach ( $objects as $object ) {
			$list .= $this->generateListItem( $object ) . '</li>';
		}

		return $list . '</ul></div>';
	}

	/**
	 * Generates a list item for an object (without the closing tag).
	 * We cannot close it as the caller might need to generate a hierarchical structure inside the list item.
	 *
	 * @since 4.1.3
	 *
	 * @param  array  $objects The object.
	 * @return string          The HTML code.
	 */
	private function generateListItem( $object ) {
		$li = '';
		if ( ! empty( $object['title'] ) ) {
			$li .= '<li>';

			// add nofollow to the link.
			if ( filter_var( $this->attributes['nofollow_links'], FILTER_VALIDATE_BOOLEAN ) ) {
				$li .= sprintf(
					'<a href="%1$s" %2$s %3$s>',
					esc_url( $object['loc'] ),
					'rel="nofollow"',
					$this->attributes['is_admin'] ? 'target="_blank"' : ''
				);
			} else {
				$li .= sprintf(
					'<a href="%1$s" %2$s>',
					esc_url( $object['loc'] ),
					$this->attributes['is_admin'] ? 'target="_blank"' : ''
				);
			}

			$li .= sprintf( '%s', esc_attr( $object['title'] ) );

			// add publication date on the list item.
			if ( ! empty( $object['date'] ) && filter_var( $this->attributes['publication_date'], FILTER_VALIDATE_BOOLEAN ) ) {
				$li .= sprintf( ' (%s)', esc_attr( $object['date'] ) );
			}

			$li .= '</a>';
		}

		return $li;
	}

	/**
	 * Generates the HTML for a hierarchical list of objects.
	 *
	 * @since 4.1.3
	 *
	 * @param  array  $objects The objects.
	 * @return string          The HTML of the hierarchical objects section.
	 */
	private function generateHierarchicalList( $objects ) {
		if ( empty( $objects ) ) {
			return '';
		}

		$objects = $this->buildHierarchicalTree( $objects );

		$list = '<ul>';
		foreach ( $objects as $object ) {
			$list .= $this->generateListItem(
				$object,
				[
					'publication_date' => $this->attributes['publication_date'],
					'nofollow_links'   => $this->attributes['nofollow_links']
				]
			);

			if ( ! empty( $object['children'] ) ) {
				$list .= $this->generateHierarchicalTree( $object );
			}
			$list .= '</li>';
		}
		$list .= '</ul>';

		return $list;
	}

	/**
	 * Recursive helper function for generateHierarchicalList().
	 * Generates hierarchical structure for objects with child objects.
	 *
	 * @since 4.1.3
	 *
	 * @param  array $object The object.
	 * @return string        The HTML code of the hierarchical tree.
	 */
	private function generateHierarchicalTree( $object ) {
		static $nestedLevel = 0;

		$tree = '<ul>';
		foreach ( $object['children'] as $child ) {
			$nestedLevel++;
			$tree .= $this->generateListItem(
				$child,
				[
					'publication_date' => $this->attributes['publication_date'],
					'nofollow_links'   => $this->attributes['nofollow_links']
				]
			);
			if ( ! empty( $child['children'] ) ) {
				$tree .= $this->generateHierarchicalTree( $child );
			}
			$tree .= '</li>';
		}
		$tree .= '</ul>';

		return $tree;
	}

	/**
	 * Builds the structure for hierarchical objects that have a parent.
	 *
	 * @since 4.1.3
	 *
	 * @param  array $objects The list of hierarchical objects.
	 * @param  int   $parent  ID of the parent node.
	 * @return array          Multidimensional array with the hierarchical structure.
	 */
	private function buildHierarchicalTree( $objects ) {
		$objects = json_decode( wp_json_encode( $objects ) );
		foreach ( $objects as $index => $child ) {
			if ( $child->parent ) {
				foreach ( $objects as $parent ) {
					// Find the parent among the other objects.
					if ( (int) $child->parent === (int) $parent->id ) {
						$parent->children[] = $child;
						unset( $objects[ $index ] );
						continue 2;
					}
					// If one of the objects already has children, try to recursively find the parent for the current child among those children.
					if ( ! empty( $parent->children ) ) {
						list( $children, $found ) = $this->findParentAmongChildren( $parent->children, $child );
						if ( $found ) {
							$parent->children = $children;
							unset( $objects[ $index ] );
							continue 2;
						}
					}
				}
			}
		}
		$objects = array_values( json_decode( wp_json_encode( $objects ), true ) );

		return $objects;
	}

	/**
	 * Recursive helper function for buildHierarchicalTree().
	 * Finds the parent for child objects whose parent is a child of another object.
	 *
	 * @since 4.1.3
	 *
	 * @param  array $parentChildren The child objects of the potential parent object.
	 * @param  array $child          The child object.
	 * @return array                 The parent's children + whether the parent was found.
	 */
	private function findParentAmongChildren( $parentChildren, $child ) {
		$found = false;
		foreach ( $parentChildren as $parentChild ) {
			if ( (int) $child->parent === (int) $parentChild->id ) {
				$parentChild->children[] = $child;
				$found                   = true;
				break;
			}
			if ( ! empty( $parentChild->children ) ) {
				return $this->findParentAmongChildren( $parentChild->children, $child );
			}
		}

		return [ $parentChildren, $found ];
	}

	/**
	 * Returns the names of the included post types or taxonomies.
	 *
	 * @since 4.1.3
	 *
	 * @param  array|string $objects      The included post types/taxonomies.
	 * @param  boolean      $arePostTypes Whether the objects are post types.
	 * @return array                      The names of the included post types/taxonomies.
	 */
	private function getIncludedObjects( $objects, $arePostTypes = true ) {
		if ( is_array( $objects ) ) {
			return $objects;
		}

		$exploded = explode( ',', $objects );
		if ( ! empty( $exploded ) ) {
			$objects = array_map( function( $object ) {
				return trim( $object );
			}, $exploded );

			$publicObjects = $arePostTypes
				? aioseo()->helpers->getPublicPostTypes( true )
				: aioseo()->helpers->getPublicTaxonomies( true );

			$objects = array_filter( $objects, function( $object ) use ( $publicObjects ) {
				return in_array( $object, $publicObjects, true );
			});
		}

		return $objects;
	}
}