<?php
/**
 * Tam Rank™ breadcrumbs
 *
 * One trail, two consumers.
 *
 * The trail logic in this file was NOT written for the visible breadcrumb — it
 * was lifted out of TAMRANK_Schema_Render::build_breadcrumb_schema(), which has
 * been building the BreadcrumbList JSON-LD from it all along. That builder now
 * calls tamrank_get_breadcrumb_trail() as well, so the crumbs a visitor reads
 * and the crumbs Google reads are the same list by construction.
 *
 * Do not add a second trail implementation here or anywhere else. The moment
 * the visible trail and the JSON-LD are computed separately they will drift,
 * and a BreadcrumbList that does not match the page is a structured-data
 * violation, not a cosmetic bug.
 *
 * No CSS ships with the plugin: the markup carries semantic classes
 * (tamrank-breadcrumb__*) and themes style it themselves.
 *
 * @package TamRank
 * @since 1.3.7
 */

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

class TamRank_Breadcrumbs
{
    public function __construct()
    {
        add_shortcode('tamrank_breadcrumb', 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(
            // Character(s) between two crumbs. Purely visual: it is hidden from
            // screen readers and never reaches the JSON-LD.
            'separator' => '/',
            // Extra class on the <nav>, for theme styling.
            'class'     => '',
        );
    }

    /* ==================================================================
     * The shared trail — the single source both consumers read
     * ================================================================*/

    /**
     * The breadcrumb trail for a post, or for the current request.
     *
     * @param WP_Post|int|null $post Post to build the trail for. Null uses the
     *                               queried object on singular views, and the
     *                               archive/search/404 context otherwise.
     * @return array<int, array{label:string, url:string, type:string}> Empty on
     *         the front page (a trail there would be a lone duplicate "Home",
     *         which Google flags as an invalid breadcrumb). The last entry has
     *         an empty url: it is the current page and must not be a link.
     */
    public static function get_trail($post = null)
    {
        if (is_numeric($post)) {
            $post = get_post((int) $post);
        }

        if ($post === null && is_singular()) {
            $queried = get_queried_object();
            if ($queried instanceof WP_Post) {
                $post = $queried;
            }
        } elseif ($post === null && defined('REST_REQUEST') && REST_REQUEST) {
            // A REST request has no query context, so is_singular() is false and
            // the trail would fall through to the archive branch and come back
            // empty. That is exactly what the block editor does when it previews
            // the breadcrumb block: it posts to /wp/v2/block-renderer/, and that
            // controller sets $GLOBALS['post'] from its post_id parameter but
            // sets up no query. Reading that global here is what makes the
            // preview show the real trail — and it lives in the shared resolver,
            // not in the block, so the shortcode and the widget behave the same
            // in a REST render too. Frontend page loads never reach this branch.
            $global_post = get_post();
            if ($global_post instanceof WP_Post) {
                $post = $global_post;
            }
        }

        $trail = ($post instanceof WP_Post)
            ? self::build_post_trail($post)
            : self::build_context_trail();

        /**
         * Filter the breadcrumb trail.
         *
         * Applied in the one place both the visible breadcrumb and the
         * BreadcrumbList JSON-LD read from, so a site that changes the trail
         * changes both at once and the two stay in sync.
         *
         * @since 1.3.7
         * @param array        $trail Entries of { label, url, type }.
         * @param WP_Post|null $post  Post the trail was built for, if any.
         */
        $trail = apply_filters('tamrank_breadcrumb_trail', $trail, $post instanceof WP_Post ? $post : null);

        return self::normalise($trail);
    }

    /**
     * Trail for a single post, page, product or custom post type item.
     *
     * This is the logic that used to live inside build_breadcrumb_schema();
     * behaviour is unchanged apart from the WP_Error guard noted below.
     *
     * @param WP_Post $post Post object.
     * @return array
     */
    private static function build_post_trail(WP_Post $post)
    {
        // No breadcrumb on the homepage: it is the root, so a trail has no
        // meaning and would produce a duplicate "Home" entry (Google marks that
        // as an invalid breadcrumb).
        $front_page_id = (int) get_option('page_on_front');
        if ($front_page_id && (int) $post->ID === $front_page_id) {
            return array();
        }

        $trail = array(self::home_entry());

        if ($post->post_type === 'post') {
            // Blog posts: add the category, or a custom taxonomy term.
            $breadcrumb_term = null;

            // Step 1: try the WP category (default).
            $categories = get_the_category($post->ID);
            if (!empty($categories)) {
                $primary_cat_id = get_post_meta($post->ID, '_yoast_wpseo_primary_category', true);
                if ($primary_cat_id) {
                    $primary_cat = get_term($primary_cat_id, 'category');
                    if ($primary_cat && !is_wp_error($primary_cat)) {
                        $breadcrumb_term = $primary_cat;
                    }
                }
                if (!$breadcrumb_term) {
                    $breadcrumb_term = $categories[0];
                }
            }

            // Step 2: fall back to custom taxonomies (clusters, etc.).
            if (!$breadcrumb_term) {
                $taxonomies = get_object_taxonomies($post->post_type, 'objects');
                foreach ($taxonomies as $tax_slug => $tax_obj) {
                    if ($tax_slug === 'post_tag' || $tax_slug === 'post_format' || !$tax_obj->public) {
                        continue;
                    }
                    $terms = get_the_terms($post->ID, $tax_slug);
                    if (!empty($terms) && !is_wp_error($terms)) {
                        $breadcrumb_term = $terms[0];
                        break;
                    }
                }
            }

            if ($breadcrumb_term) {
                foreach (self::term_hierarchy($breadcrumb_term) as $term) {
                    if (!self::crumb_term_is_public($term)) {
                        continue;
                    }
                    $term_link = get_term_link($term);
                    if (!is_wp_error($term_link)) {
                        $trail[] = array(
                            'label' => $term->name,
                            'url'   => $term_link,
                            'type'  => 'term',
                        );
                    }
                }
            }
        } elseif ($post->post_type === 'page') {
            // Pages: add the parent pages (hierarchy).
            $ancestors = get_post_ancestors($post->ID);

            if (!empty($ancestors)) {
                // Ancestors come back closest-parent-first.
                $ancestors = array_reverse($ancestors);

                foreach ($ancestors as $ancestor_id) {
                    $ancestor = get_post($ancestor_id);
                    if (self::crumb_post_is_public($ancestor)) {
                        $trail[] = array(
                            'label' => get_the_title($ancestor),
                            'url'   => get_permalink($ancestor),
                            'type'  => 'ancestor',
                        );
                    }
                }
            }
        } elseif ($post->post_type === 'product' && function_exists('wc_get_product')) {
            // WooCommerce products: add the product category.
            $terms = get_the_terms($post->ID, 'product_cat');

            if (!empty($terms) && !is_wp_error($terms)) {
                $primary_cat    = null;
                $primary_cat_id = get_post_meta($post->ID, '_yoast_wpseo_primary_product_cat', true);

                if ($primary_cat_id) {
                    foreach ($terms as $term) {
                        if ((int) $term->term_id === (int) $primary_cat_id) {
                            $primary_cat = $term;
                            break;
                        }
                    }
                }

                if (!$primary_cat) {
                    $primary_cat = $terms[0];
                }

                foreach (self::term_hierarchy($primary_cat) as $term) {
                    if (!self::crumb_term_is_public($term)) {
                        continue;
                    }
                    // The schema builder used to pass get_term_link() straight
                    // through here without an is_wp_error() check, unlike the
                    // post branch above. A broken product category would have
                    // put a WP_Error object in the JSON-LD; it would now also
                    // land in the visible markup. Guarded, like the post branch.
                    $term_link = get_term_link($term);
                    if (!is_wp_error($term_link)) {
                        $trail[] = array(
                            'label' => $term->name,
                            'url'   => $term_link,
                            'type'  => 'term',
                        );
                    }
                }
            }
        } else {
            // Custom post types: add the archive link when there is one.
            $post_type_obj = get_post_type_object($post->post_type);

            if ($post_type_obj && $post_type_obj->has_archive) {
                $archive_link = get_post_type_archive_link($post->post_type);

                if ($archive_link) {
                    $trail[] = array(
                        'label' => $post_type_obj->labels->name,
                        'url'   => $archive_link,
                        'type'  => 'archive',
                    );
                }
            }
        }

        // The current page is always last and never a link (Google's guideline).
        $trail[] = array(
            'label' => get_the_title($post),
            'url'   => '',
            'type'  => 'current',
        );

        return $trail;
    }

    /**
     * Trail for the non-singular views: archives, search, 404.
     *
     * These have no BreadcrumbList counterpart — the schema renderer only ever
     * runs on singular posts — so nothing here can desync the JSON-LD.
     *
     * @return array
     */
    private static function build_context_trail()
    {
        if (is_front_page()) {
            return array();
        }

        $trail = array(self::home_entry());

        if (is_category() || is_tag() || is_tax()) {
            $term = get_queried_object();
            if ($term instanceof WP_Term) {
                $hierarchy = self::term_hierarchy($term);
                $last      = array_pop($hierarchy);

                foreach ($hierarchy as $ancestor) {
                    if (!self::crumb_term_is_public($ancestor)) {
                        continue;
                    }
                    $link = get_term_link($ancestor);
                    if (!is_wp_error($link)) {
                        $trail[] = array(
                            'label' => $ancestor->name,
                            'url'   => $link,
                            'type'  => 'term',
                        );
                    }
                }

                if ($last) {
                    $trail[] = array(
                        'label' => $last->name,
                        'url'   => '',
                        'type'  => 'current',
                    );
                }
            }
        } elseif (is_post_type_archive()) {
            $post_type_obj = get_queried_object();
            if ($post_type_obj && isset($post_type_obj->labels->name)) {
                $trail[] = array(
                    'label' => $post_type_obj->labels->name,
                    'url'   => '',
                    'type'  => 'current',
                );
            }
        } elseif (is_home()) {
            // The blog index on a site with a static front page.
            $blog_page_id = (int) get_option('page_for_posts');
            $trail[]      = array(
                'label' => $blog_page_id ? get_the_title($blog_page_id) : tamrank__('breadcrumb_blog', 'Blog'),
                'url'   => '',
                'type'  => 'current',
            );
        } elseif (is_author()) {
            $author = get_queried_object();
            if ($author instanceof WP_User) {
                $trail[] = array(
                    'label' => $author->display_name,
                    'url'   => '',
                    'type'  => 'current',
                );
            }
        } elseif (is_search()) {
            $trail[] = array(
                /* translators: %s: the search term the visitor typed. */
                'label' => sprintf(tamrank__('breadcrumb_search_results', 'Search results for “%s”'), get_search_query()),
                'url'   => '',
                'type'  => 'current',
            );
        } elseif (is_404()) {
            $trail[] = array(
                'label' => tamrank__('breadcrumb_not_found', 'Page not found'),
                'url'   => '',
                'type'  => 'current',
            );
        } elseif (is_date()) {
            $trail[] = array(
                'label' => wp_strip_all_tags(get_the_archive_title()),
                'url'   => '',
                'type'  => 'current',
            );
        }

        // Only the "Home" entry left means we had nothing to say about this
        // view; a lone Home crumb is noise, so render nothing at all.
        return (count($trail) > 1) ? $trail : array();
    }

    /**
     * The "Home" entry every trail starts with.
     *
     * @return array
     */
    private static function home_entry()
    {
        return array(
            // Same key the HTML sitemap uses for its homepage group — one word,
            // one translation.
            'label' => tamrank__('frontend_home', 'Home'),
            'url'   => home_url('/'),
            'type'  => 'home',
        );
    }

    /**
     * May this post appear as a crumb the visitor (and Google) can follow?
     *
     * A breadcrumb is navigation: every link in it is a promise that the
     * destination exists and is public. Without this guard the trail happily
     * linked a concept, een prive pagina, een prullenbakitem, een
     * wachtwoordpagina of een op noindex gezette pagina — met /?page_id=N-URL's
     * erbij, want een niet-gepubliceerde ouder heeft geen mooie permalink. Die
     * kruimels stonden ook in de BreadcrumbList JSON-LD, dus ze lekten
     * rechtstreeks naar Google.
     *
     * De eerste twee uitsluitingen zijn precies wat de sitemap al doet
     * (includes/sitemap.php:531 post_status=publish en :537 noindex); de derde
     * is de wachtwoordpagina, die geen sitemapequivalent heeft maar in een
     * breadcrumb net zo goed een dood pad is.
     *
     * Alleen de uitgesloten kruimel valt weg, niet de keten erboven: een
     * publieke grootouder blijft een geldig, bereikbaar pad en die weggooien
     * kost de bezoeker echte navigatie. Zo doet de sitemap het ook — die slaat
     * per URL over en cascadeert niet.
     *
     * @param WP_Post|null $post Kandidaat-voorouder.
     * @return bool
     */
    private static function crumb_post_is_public($post)
    {
        if (!$post instanceof WP_Post) {
            return false;
        }

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

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

        return get_post_meta($post->ID, '_tam_rank_noindex', true) != '1';
    }

    /**
     * May this term appear as a linked crumb?
     *
     * Same rule as includes/sitemap.php:720. Applies to linked ancestor terms
     * only — the current view's own term is the page you are on and always
     * keeps its (unlinked) crumb, exactly like a noindex post keeps its own.
     *
     * @param WP_Term|mixed $term Kandidaat-term.
     * @return bool
     */
    private static function crumb_term_is_public($term)
    {
        if (!$term instanceof WP_Term) {
            return false;
        }

        return get_term_meta($term->term_id, '_tam_rank_noindex', true) != '1';
    }

    /**
     * A term plus its ancestors, outermost first.
     *
     * @param WP_Term $term Term to walk up from.
     * @return WP_Term[]
     */
    private static function term_hierarchy($term)
    {
        $hierarchy = array();
        $current   = $term;
        $taxonomy  = $term->taxonomy;
        // Cyclic term parents exist in the wild; stop the walk if we revisit one.
        $seen = array();

        while ($current instanceof WP_Term && !isset($seen[$current->term_id])) {
            $seen[$current->term_id] = true;
            array_unshift($hierarchy, $current);

            if (!$current->parent) {
                break;
            }

            $parent = get_term($current->parent, $taxonomy);
            if (!$parent || is_wp_error($parent)) {
                break;
            }
            $current = $parent;
        }

        return $hierarchy;
    }

    /**
     * Drop malformed entries and force the value types.
     *
     * Runs after the tamrank_breadcrumb_trail filter, so a third party cannot
     * push an object or a null into either consumer.
     *
     * @param mixed $trail Raw trail.
     * @return array
     */
    private static function normalise($trail)
    {
        if (!is_array($trail)) {
            return array();
        }

        $clean = array();
        foreach ($trail as $entry) {
            if (!is_array($entry) || !isset($entry['label'])) {
                continue;
            }
            $label = is_scalar($entry['label']) ? trim((string) $entry['label']) : '';
            if ($label === '') {
                continue;
            }
            $url = (isset($entry['url']) && is_scalar($entry['url'])) ? (string) $entry['url'] : '';

            $clean[] = array(
                'label' => $label,
                'url'   => $url,
                'type'  => (isset($entry['type']) && is_scalar($entry['type'])) ? (string) $entry['type'] : '',
            );
        }

        return $clean;
    }

    /* ==================================================================
     * Rendering
     * ================================================================*/

    /**
     * The breadcrumb markup for the current request.
     *
     * @param array $atts Attributes, see get_defaults().
     * @return string HTML, or an empty string when there is no trail.
     */
    public static function render($atts = array())
    {
        $atts  = shortcode_atts(self::get_defaults(), (array) $atts, 'tamrank_breadcrumb');
        $trail = self::get_trail();

        if (empty($trail)) {
            return '';
        }

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

        $separator = (string) $atts['separator'];

        // Flat <nav> with inline spans, not <nav><ol><li>. The plugin ships no
        // CSS, and an unstyled <ol> inherits the theme's list rules: on Divi the
        // trail comes out as a vertical numbered list. Yoast and Rank Math both
        // emit a flat chain for the same reason — it reads correctly in any
        // theme without a single line of stylesheet. Themes that want list
        // semantics can rebuild the markup from tamrank_get_breadcrumb_trail().
        $html = '<nav class="' . esc_attr($nav_class) . '" aria-label="' . esc_attr(tamrank__('breadcrumb_aria_label', 'Breadcrumb')) . '">';

        $last_index = count($trail) - 1;
        foreach ($trail as $index => $entry) {
            $is_current = ($index === $last_index);

            if ($index > 0 && $separator !== '') {
                $html .= ' <span class="tamrank-breadcrumb__separator" aria-hidden="true">' . esc_html($separator) . '</span> ';
            }

            $item_class = 'tamrank-breadcrumb__item';
            if ($is_current) {
                $item_class .= ' tamrank-breadcrumb__item--current';
            }

            $html .= '<span class="' . esc_attr($item_class) . '">';

            if ($entry['url'] !== '' && !$is_current) {
                $html .= '<a class="tamrank-breadcrumb__link" href="' . esc_url($entry['url']) . '">' . esc_html($entry['label']) . '</a>';
            } else {
                $html .= '<span class="tamrank-breadcrumb__current" aria-current="page">' . esc_html($entry['label']) . '</span>';
            }

            $html .= '</span>';
        }

        $html .= '</nav>';

        return $html;
    }
}

/**
 * The breadcrumb trail as plain data.
 *
 * Both the visible breadcrumb and the BreadcrumbList JSON-LD read this
 * function; nothing else may compute a trail of its own.
 *
 * @since 1.3.7
 * @param WP_Post|int|null $post Post to build the trail for, or null for the
 *                               current request.
 * @return array<int, array{label:string, url:string, type:string}> The last
 *         entry is the current page and carries an empty url.
 */
function tamrank_get_breadcrumb_trail($post = null)
{
    if (!class_exists('TamRank_Breadcrumbs')) {
        return array();
    }

    return TamRank_Breadcrumbs::get_trail($post);
}

/**
 * Return the breadcrumb markup for the current request.
 *
 * @since 1.3.7
 * @param array $atts Same attributes as the [tamrank_breadcrumb] shortcode.
 * @return string HTML, already escaped. Empty on the homepage.
 */
function tamrank_get_the_breadcrumbs($atts = array())
{
    if (!class_exists('TamRank_Breadcrumbs')) {
        return '';
    }

    return TamRank_Breadcrumbs::render($atts);
}

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