<?php
/**
 * Tam Rank™ HTML sitemap
 *
 * A human-readable version of the XML sitemap, placed on a page with the
 * [tamrank_sitemap] shortcode or the tamrank_the_sitemap() template function.
 *
 * The set of links is NOT collected here. It comes from
 * TamRank_Sitemap::get_content_groups() — the same collector the XML sitemap
 * flattens into its <urlset>. That is deliberate: the XML sitemap skips posts
 * and terms marked noindex, and honours the content type config. A second
 * collector would sooner or later publish a page full of links to noindex
 * content, which is worse than not having the feature at all.
 *
 * No CSS ships with the plugin. The markup carries semantic classes
 * (tamrank-sitemap__*) and themes style it themselves.
 *
 * @package TamRank
 * @since 1.3.7
 */

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

class TamRank_HTML_Sitemap
{
    /** Option holding the cache generation counter. */
    const VERSION_OPTION = 'tam_rank_html_sitemap_version';

    /** How long one rendering stays cached. */
    const CACHE_TTL = 12 * HOUR_IN_SECONDS;

    /** Heading levels a shortcode may pick for the per-group headings. */
    const ALLOWED_HEADING_TAGS = array('h2', 'h3', 'h4', 'h5', 'h6');

    /**
     * Values a yes/no attribute reads as "no".
     *
     * A constant rather than an inline list because the block editor's toggle
     * has to decide the same way is_yes() does: a page whose shortcode said
     * home="off" must show an unchecked toggle after it is converted to a block.
     */
    const NO_VALUES = array('no', 'false', '0', 'off');

    public function __construct()
    {
        add_shortcode('tamrank_sitemap', array($this, 'shortcode'));
    }

    /**
     * Render the shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string HTML
     */
    public function shortcode($atts)
    {
        return self::render(is_array($atts) ? $atts : array());
    }

    /**
     * Default shortcode attributes.
     *
     * @return array
     */
    public static function get_defaults()
    {
        return array(
            // Comma-separated post type slugs to show. Empty = every post type
            // the XML sitemap publishes. A slug that is NOT in the sitemap
            // config is ignored: this narrows the set, it can never widen it.
            'post_types'  => '',
            // Same, for taxonomies.
            'taxonomies'  => '',
            // Show the homepage entry (the XML sitemap has one too).
            'home'        => 'yes',
            // Heading level for the per-group headings.
            'heading_tag' => 'h2',
            // Extra class on the wrapper, for theme styling.
            'class'       => '',
        );
    }

    /**
     * The full HTML sitemap markup.
     *
     * @param array $atts Attributes, see get_defaults().
     * @return string HTML, ready to echo.
     */
    public static function render($atts = array())
    {
        $atts = shortcode_atts(self::get_defaults(), (array) $atts, 'tamrank_sitemap');

        $cache_key = self::cache_key($atts);
        $cached    = get_transient($cache_key);
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }

        $html = self::build_html($atts);

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

        return $html;
    }

    /**
     * The groups this rendering shows, after applying the shortcode filters.
     *
     * Split out from build_html() so the regression harness can compare this
     * set against the XML sitemap's set directly, without parsing markup.
     *
     * @param array $atts Attributes, see get_defaults().
     * @return array Groups in TamRank_Sitemap::get_content_groups() shape.
     */
    public static function get_groups($atts = array())
    {
        $atts = shortcode_atts(self::get_defaults(), (array) $atts, 'tamrank_sitemap');

        $sitemap = self::get_sitemap();
        if (!$sitemap) {
            return array();
        }

        // false: the HTML sitemap lists pages, not image files, so the image
        // collection is pure overhead here. It does not affect which URLs come
        // back — see TamRank_Sitemap::get_post_type_urls().
        $groups = $sitemap->get_content_groups(false);

        $only_post_types = self::parse_slug_list($atts['post_types']);
        $only_taxonomies = self::parse_slug_list($atts['taxonomies']);
        $show_home       = self::is_yes($atts['home']);

        $filtered = array();
        // A static front page is published twice by the XML sitemap: once as
        // the sitemap's own homepage entry and once as an ordinary page, both
        // resolving to home_url('/'). A duplicate <loc> is invisible in XML,
        // but on a page a visitor reads it is simply the same link listed
        // twice. Keep the first occurrence and drop the repeat — the SET of
        // urls stays identical to the XML sitemap's, which is what matters.
        $seen = array();

        foreach ($groups as $group) {
            if ($group['type'] === 'home' && !$show_home) {
                continue;
            }
            if ($group['type'] === 'post_type' && $only_post_types && !in_array($group['slug'], $only_post_types, true)) {
                continue;
            }
            if ($group['type'] === 'taxonomy' && $only_taxonomies && !in_array($group['slug'], $only_taxonomies, true)) {
                continue;
            }

            $urls = array();
            foreach ($group['urls'] as $url) {
                $loc = isset($url['loc']) ? (string) $url['loc'] : '';
                if ($loc === '' || isset($seen[$loc])) {
                    continue;
                }
                $seen[$loc] = true;
                $urls[]     = $url;
            }

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

            $group['urls'] = $urls;
            $filtered[]    = $group;
        }

        return $filtered;
    }

    /**
     * Build the markup for the given attributes (uncached).
     *
     * @param array $atts Attributes, already merged with the defaults.
     * @return string HTML
     */
    private static function build_html($atts)
    {
        $groups = self::get_groups($atts);

        $wrapper_class = 'tamrank-sitemap';
        if (!empty($atts['class'])) {
            $wrapper_class .= ' ' . sanitize_html_class($atts['class']);
        }

        if (empty($groups)) {
            return '<div class="' . esc_attr($wrapper_class) . '"><p class="tamrank-sitemap__empty">'
                . esc_html(tamrank__('html_sitemap_empty', 'No published content to show yet.'))
                . '</p></div>';
        }

        $heading_tag = strtolower((string) $atts['heading_tag']);
        if (!in_array($heading_tag, self::ALLOWED_HEADING_TAGS, true)) {
            $heading_tag = 'h2';
        }

        $html = '<div class="' . esc_attr($wrapper_class) . '">';

        foreach ($groups as $group) {
            $html .= '<section class="tamrank-sitemap__section tamrank-sitemap__section--' . esc_attr($group['slug']) . '">';
            $html .= '<' . $heading_tag . ' class="tamrank-sitemap__heading">' . esc_html($group['label']) . '</' . $heading_tag . '>';
            $html .= '<ul class="tamrank-sitemap__list">';

            foreach ($group['urls'] as $url) {
                $loc = isset($url['loc']) ? (string) $url['loc'] : '';
                if ($loc === '') {
                    continue;
                }
                $title = isset($url['title']) && $url['title'] !== ''
                    ? (string) $url['title']
                    : $loc;

                $html .= '<li class="tamrank-sitemap__item">';
                $html .= '<a class="tamrank-sitemap__link" href="' . esc_url($loc) . '">' . esc_html($title) . '</a>';
                $html .= '</li>';
            }

            $html .= '</ul></section>';
        }

        $html .= '</div>';

        return $html;
    }

    /**
     * The live TamRank_Sitemap instance.
     *
     * Deliberately does NOT construct one as a fallback: the constructor
     * registers hooks, so a second instance would double every sitemap action.
     *
     * @return TamRank_Sitemap|null
     */
    private static function get_sitemap()
    {
        global $tam_rank_sitemap;

        return ($tam_rank_sitemap instanceof TamRank_Sitemap) ? $tam_rank_sitemap : null;
    }

    /**
     * Transient key for one attribute combination.
     *
     * @param array $atts Attributes, already merged with the defaults.
     * @return string
     */
    private static function cache_key($atts)
    {
        ksort($atts);

        return 'tam_rank_html_sitemap_' . md5(self::get_cache_version() . '|' . wp_json_encode($atts));
    }

    /**
     * Current cache generation.
     *
     * @return int
     */
    public static function get_cache_version()
    {
        return (int) get_option(self::VERSION_OPTION, 1);
    }

    /**
     * Orphan every cached rendering at once.
     *
     * Called from TamRank_Sitemap::clear_sitemap_cache(), so the HTML sitemap
     * refreshes on exactly the same events as the XML one.
     */
    public static function bump_cache_version()
    {
        update_option(self::VERSION_OPTION, self::get_cache_version() + 1, false);
    }

    /**
     * Split a comma-separated attribute into sanitised slugs.
     *
     * @param string $value Raw attribute value.
     * @return string[] Empty array when nothing usable was given.
     */
    private static function parse_slug_list($value)
    {
        $slugs = array();
        foreach (explode(',', (string) $value) as $part) {
            $slug = sanitize_key(trim($part));
            if ($slug !== '') {
                $slugs[] = $slug;
            }
        }

        return $slugs;
    }

    /**
     * Read a yes/no style shortcode attribute.
     *
     * @param mixed $value Raw attribute value.
     * @return bool
     */
    private static function is_yes($value)
    {
        return !in_array(strtolower(trim((string) $value)), self::NO_VALUES, true);
    }
}

/**
 * Return the HTML sitemap markup.
 *
 * @since 1.3.7
 * @param array $atts Same attributes as the [tamrank_sitemap] shortcode.
 * @return string HTML, already escaped.
 */
function tamrank_get_the_sitemap($atts = array())
{
    if (!class_exists('TamRank_HTML_Sitemap')) {
        return '';
    }

    return TamRank_HTML_Sitemap::render($atts);
}

/**
 * Echo the HTML sitemap.
 *
 * Template usage: <?php tamrank_the_sitemap(); ?>
 *
 * @since 1.3.7
 * @param array $atts Same attributes as the [tamrank_sitemap] shortcode.
 */
function tamrank_the_sitemap($atts = array())
{
    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- markup is assembled in build_html(), where every URL goes through esc_url() and every label through esc_html().
    echo tamrank_get_the_sitemap($atts);
}
