<?php
// Loader: add 'sitemap_html' => ['class-sitemap-html.php', 'Livada_SEO_Sitemap_HTML'] to $feature_map

/**
 * Livada SEO — HTML Sitemap Module
 *
 * Registers the [livada_sitemap] shortcode and the livada_seo_sitemap_html()
 * template helper.  Output lists all published content organised by type:
 *   - Pages (hierarchical)
 *   - Posts grouped by category (newest first)
 *   - Categories with post count
 *
 * Output is cached in transient `livada_seo_sitemap_html` for 12 hours.
 * The transient is deleted on every save_post event so the listing stays
 * fresh after content changes.
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_Sitemap_HTML {

	// ── Constants ─────────────────────────────────────────────────────────────

	/** Transient key for the cached HTML output. */
	const CACHE_KEY = 'livada_seo_sitemap_html';

	/** Transient TTL in seconds (12 hours). */
	const CACHE_TTL = 43200;

	// ── Bootstrap ─────────────────────────────────────────────────────────────

	/**
	 * Registers all hooks for this module.
	 * Called once by Livada_SEO_Loader::init().
	 */
	public static function init(): void {
		add_shortcode( 'livada_sitemap', [ __CLASS__, 'shortcode' ] );

		// Invalidate cache whenever a post is created, updated, or deleted.
		add_action( 'save_post',   [ __CLASS__, 'invalidate_cache' ] );
		add_action( 'delete_post', [ __CLASS__, 'invalidate_cache' ] );
	}

	// ── Shortcode ─────────────────────────────────────────────────────────────

	/**
	 * [livada_sitemap] shortcode callback.
	 *
	 * @return string HTML output.
	 */
	public static function shortcode(): string {
		return self::render();
	}

	// ── Cache invalidation ────────────────────────────────────────────────────

	/**
	 * Deletes the cached HTML sitemap transient.
	 */
	public static function invalidate_cache(): void {
		delete_transient( self::CACHE_KEY );
	}

	// ── Rendering ─────────────────────────────────────────────────────────────

	/**
	 * Builds (or retrieves from cache) the full HTML sitemap string.
	 *
	 * @return string Safe HTML markup ready for output.
	 */
	public static function render(): string {
		$cached = get_transient( self::CACHE_KEY );

		if ( false !== $cached ) {
			return $cached; // Already escaped when first built.
		}

		$html = self::build_html();

		set_transient( self::CACHE_KEY, $html, self::CACHE_TTL );

		return $html;
	}

	/**
	 * Builds the HTML sitemap markup from scratch.
	 *
	 * @return string Escaped HTML.
	 */
	private static function build_html(): string {
		$post_types = self::get_post_types();
		$taxonomies = self::get_taxonomies();

		ob_start();

		// Minimal inline styles — keeps the output self-contained without a
		// separate stylesheet enqueue for a feature this lightweight.
		echo '<style>.livada-sitemap-html ul{list-style:none;padding-left:1.25em;margin:.25em 0}'
			. '.livada-sitemap-html>ul{padding-left:0}'
			. '.livada-sitemap-html .date{font-size:.85em;color:#666;margin-left:.4em}'
			. '</style>' . "\n";

		echo '<div class="livada-sitemap-html">' . "\n";

		// ── Pages (hierarchical) ──────────────────────────────────────────────

		if ( in_array( 'page', $post_types, true ) ) {
			$pages = self::get_pages_hierarchical();

			if ( ! empty( $pages ) ) {
				echo '<h2>' . esc_html__( 'Pages', 'livada-seo' ) . '</h2>' . "\n";
				echo self::render_page_list( $pages );
			}
		}

		// ── Posts grouped by category ─────────────────────────────────────────

		if ( in_array( 'post', $post_types, true ) ) {
			$posts_by_cat = self::get_posts_by_category();

			if ( ! empty( $posts_by_cat ) ) {
				echo '<h2>' . esc_html__( 'Articles', 'livada-seo' ) . '</h2>' . "\n";

				foreach ( $posts_by_cat as $cat_name => $posts ) {
					echo '<h3>' . esc_html( $cat_name ) . '</h3>' . "\n";
					echo '<ul>' . "\n";

					foreach ( $posts as $post ) {
						$url  = get_permalink( $post );
						$date = get_the_date( 'd/m/Y', $post );

						echo "\t<li>"
							. '<a href="' . esc_url( $url ) . '">' . esc_html( get_the_title( $post ) ) . '</a>'
							. ' <span class="date">' . esc_html( $date ) . '</span>'
							. "</li>\n";
					}

					echo "</ul>\n";
				}
			}
		}

		// ── Other enabled post types (not page/post) ──────────────────────────

		foreach ( $post_types as $post_type ) {
			if ( in_array( $post_type, [ 'page', 'post' ], true ) ) {
				continue;
			}

			if ( ! post_type_exists( $post_type ) ) {
				continue;
			}

			$type_obj = get_post_type_object( $post_type );
			$label    = $type_obj ? $type_obj->labels->name : $post_type;

			$posts = self::query_posts( $post_type );

			if ( empty( $posts ) ) {
				continue;
			}

			echo '<h2>' . esc_html( $label ) . '</h2>' . "\n";
			echo '<ul>' . "\n";

			foreach ( $posts as $post ) {
				$url  = get_permalink( $post );
				$date = get_the_date( 'd/m/Y', $post );

				echo "\t<li>"
					. '<a href="' . esc_url( $url ) . '">' . esc_html( get_the_title( $post ) ) . '</a>'
					. ' <span class="date">' . esc_html( $date ) . '</span>'
					. "</li>\n";
			}

			echo "</ul>\n";
		}

		// ── Categories (and other enabled taxonomies) ─────────────────────────

		foreach ( $taxonomies as $taxonomy ) {
			if ( ! taxonomy_exists( $taxonomy ) ) {
				continue;
			}

			$tax_obj = get_taxonomy( $taxonomy );
			$label   = $tax_obj ? $tax_obj->labels->name : $taxonomy;

			$terms = get_terms(
				[
					'taxonomy'   => $taxonomy,
					'hide_empty' => true,
					'orderby'    => 'name',
					'order'      => 'ASC',
				]
			);

			if ( is_wp_error( $terms ) || empty( $terms ) ) {
				continue;
			}

			echo '<h2>' . esc_html( $label ) . '</h2>' . "\n";
			echo '<ul>' . "\n";

			foreach ( $terms as $term ) {
				$exclude = get_term_meta( $term->term_id, '_livada_seo_sitemap_exclude', true );

				if ( $exclude === '1' || $exclude === true ) {
					continue;
				}

				$term_link = get_term_link( $term );

				if ( is_wp_error( $term_link ) ) {
					continue;
				}

				echo "\t<li>"
					. '<a href="' . esc_url( $term_link ) . '">' . esc_html( $term->name ) . '</a>'
					. ' (' . (int) $term->count . ')'
					. "</li>\n";
			}

			echo "</ul>\n";
		}

		echo '</div>' . "\n";

		return (string) ob_get_clean();
	}

	// ── Page helpers ──────────────────────────────────────────────────────────

	/**
	 * Returns all published, non-excluded pages as a nested hierarchy.
	 *
	 * Structure: array of WP_Post objects with a `children` property added.
	 * Only top-level pages (parent = 0) are included at the root; child pages
	 * are nested under their parent's `children` key.
	 *
	 * @return WP_Post[] Top-level pages, each optionally carrying children.
	 */
	private static function get_pages_hierarchical(): array {
		$all_pages = get_pages(
			[
				'post_status'    => 'publish',
				'orderby'        => 'menu_order',
				'order'          => 'ASC',
				'meta_query'     => [
					'relation' => 'AND',
					[
						'relation' => 'OR',
						[
							'key'     => '_livada_seo_sitemap_exclude',
							'compare' => 'NOT EXISTS',
						],
						[
							'key'     => '_livada_seo_sitemap_exclude',
							'value'   => '1',
							'compare' => '!=',
						],
					],
					[
						'relation' => 'OR',
						[
							'key'     => '_livada_seo_noindex',
							'compare' => 'NOT EXISTS',
						],
						[
							'key'     => '_livada_seo_noindex',
							'value'   => '1',
							'compare' => '!=',
						],
					],
				],
			]
		);

		if ( empty( $all_pages ) ) {
			return [];
		}

		// Index all pages by ID and initialise children bucket.
		$indexed = [];

		foreach ( $all_pages as $page ) {
			$page->children = []; // phpcs:ignore WordPress.WP.GlobalVariablesOverride
			$indexed[ $page->ID ] = $page;
		}

		// Build tree: attach children to their parent.
		$roots = [];

		foreach ( $indexed as $id => $page ) {
			if ( $page->post_parent && isset( $indexed[ $page->post_parent ] ) ) {
				$indexed[ $page->post_parent ]->children[] = $page;
			} else {
				$roots[] = $page;
			}
		}

		return $roots;
	}

	/**
	 * Recursively renders a nested page list as `<ul><li>` HTML.
	 *
	 * @param  WP_Post[] $pages List of WP_Post objects with optional `children`.
	 * @return string
	 */
	private static function render_page_list( array $pages ): string {
		if ( empty( $pages ) ) {
			return '';
		}

		$html = "<ul>\n";

		foreach ( $pages as $page ) {
			$url = get_permalink( $page );

			$html .= "\t<li>"
				. '<a href="' . esc_url( $url ) . '">' . esc_html( get_the_title( $page ) ) . '</a>';

			if ( ! empty( $page->children ) ) {
				$html .= "\n" . self::render_page_list( $page->children );
			}

			$html .= "</li>\n";
		}

		$html .= "</ul>\n";

		return $html;
	}

	// ── Post helpers ──────────────────────────────────────────────────────────

	/**
	 * Returns published posts grouped by their primary category, newest first.
	 *
	 * Posts without a category fall into an "Uncategorized" bucket.
	 * Both noindex and sitemap-excluded posts are omitted.
	 *
	 * @return array<string, WP_Post[]> Associative array keyed by category name.
	 */
	private static function get_posts_by_category(): array {
		$posts = self::query_posts( 'post' );

		if ( empty( $posts ) ) {
			return [];
		}

		$by_cat = [];

		foreach ( $posts as $post ) {
			$cats = get_the_category( $post->ID );

			if ( ! empty( $cats ) ) {
				$cat_name = $cats[0]->name;
			} else {
				/* translators: fallback category label in HTML sitemap */
				$cat_name = __( 'Uncategorized', 'livada-seo' );
			}

			$by_cat[ $cat_name ][] = $post;
		}

		return $by_cat;
	}

	/**
	 * Runs a lightweight WP_Query to fetch published, non-excluded posts for
	 * a given post type, ordered by date descending.
	 *
	 * @param  string $post_type Post-type slug.
	 * @return WP_Post[]
	 */
	private static function query_posts( string $post_type ): array {
		$args = [
			'post_type'              => $post_type,
			'post_status'            => 'publish',
			'posts_per_page'         => 500,
			'orderby'                => 'date',
			'order'                  => 'DESC',
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => false,
			'meta_query'             => [
				'relation' => 'AND',
				[
					'relation' => 'OR',
					[
						'key'     => '_livada_seo_sitemap_exclude',
						'compare' => 'NOT EXISTS',
					],
					[
						'key'     => '_livada_seo_sitemap_exclude',
						'value'   => '1',
						'compare' => '!=',
					],
				],
				[
					'relation' => 'OR',
					[
						'key'     => '_livada_seo_noindex',
						'compare' => 'NOT EXISTS',
					],
					[
						'key'     => '_livada_seo_noindex',
						'value'   => '1',
						'compare' => '!=',
					],
				],
			],
		];

		$query = new WP_Query( $args );
		$posts = $query->posts;
		wp_reset_postdata();

		return is_array( $posts ) ? $posts : [];
	}

	// ── Settings helpers ──────────────────────────────────────────────────────

	/**
	 * Returns the configured post types for the sitemap.
	 *
	 * @return string[]
	 */
	private static function get_post_types(): array {
		$types = Livada_SEO_Loader::get_setting( 'sitemap_post_types', [ 'post', 'page' ] );
		return is_array( $types ) ? $types : [ 'post', 'page' ];
	}

	/**
	 * Returns the configured taxonomies for the sitemap.
	 *
	 * @return string[]
	 */
	private static function get_taxonomies(): array {
		$taxes = Livada_SEO_Loader::get_setting( 'sitemap_taxonomies', [ 'category' ] );
		return is_array( $taxes ) ? $taxes : [ 'category' ];
	}
}

// ── Global template helper ────────────────────────────────────────────────────

if ( ! function_exists( 'livada_seo_sitemap_html' ) ) {
	/**
	 * Outputs the HTML sitemap directly.
	 *
	 * Usage in theme templates:
	 *   <?php livada_seo_sitemap_html(); ?>
	 */
	function livada_seo_sitemap_html(): void {
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo Livada_SEO_Sitemap_HTML::render();
	}
}
