<?php

declare(strict_types=1);

/**
 * Related Content Gutenberg Blocks (Task 10).
 *
 *   prorank-seo/related-posts   — overlap-by-prominent-words, falls back to
 *                                 taxonomy then recent same-post-type
 *   prorank-seo/sibling-pages   — same-parent siblings of the current page
 *   prorank-seo/subpages        — direct children of the current page
 *
 * Dynamic blocks only — render is server-side so we can:
 *   - reuse T8's `_prorank_pw_terms` index without re-tokenizing at view time
 *   - exclude noindex / private / draft / password-protected pages
 *   - keep queries bounded (candidate pool ≤ 50, output ≤ 12)
 *   - escape every URL/title/attribute on the way out
 *
 * @package ProRank\SEO\Modules\Content\InternalLinking
 * @since   1.3.1
 */

namespace ProRank\SEO\Modules\Content\InternalLinking;

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


class RelatedContentBlocks {

    public const BLOCK_RELATED_POSTS = 'prorank-seo/related-posts';
    public const BLOCK_SIBLINGS      = 'prorank-seo/sibling-pages';
    public const BLOCK_SUBPAGES      = 'prorank-seo/subpages';

    public const CANDIDATE_POOL_CAP = 50;
    public const MIN_MAX_LINKS      = 1;
    public const MAX_MAX_LINKS      = 12;
    public const DEFAULT_MAX_LINKS  = 5;

    /** Per-post-type top-N prominent terms we use for scoring. */
    private const TOP_TERMS_USED = 10;

    public function register(): void {
        if ( ! function_exists( 'add_action' ) ) {
            return;
        }
        add_action( 'init',                        [ $this, 'register_blocks' ] );
        add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_editor_assets' ] );
        add_filter( 'block_categories_all',        [ $this, 'register_block_category' ], 10, 2 );
    }

    /**
     * Add the prorank-seo block category if no other module beat us to it.
     * The other ProRank blocks register the same category too; this is the
     * same defensive shape `BreadcrumbsModule` uses.
     */
    public function register_block_category( $categories, $post ): array {
        if ( ! is_array( $categories ) ) {
            return $categories;
        }
        foreach ( $categories as $category ) {
            if ( ( $category['slug'] ?? '' ) === 'prorank-seo' ) {
                return $categories;
            }
        }
        return array_merge( $categories, [ [
            'slug'  => 'prorank-seo',
            'title' => __( 'ProRank SEO', 'prorank-seo' ),
            'icon'  => 'chart-line',
        ] ] );
    }

    public function register_blocks(): void {
        if ( ! function_exists( 'register_block_type' ) ) {
            return;
        }

        $common = [
            'heading'  => [ 'type' => 'string', 'default' => '' ],
            'maxLinks' => [ 'type' => 'number', 'default' => self::DEFAULT_MAX_LINKS ],
            'layout'   => [ 'type' => 'string', 'default' => 'list' ],
            'className'=> [ 'type' => 'string', 'default' => '' ],
        ];

        register_block_type( self::BLOCK_RELATED_POSTS, [
            'api_version'     => 3,
            'render_callback' => [ $this, 'render_related_posts' ],
            'attributes'      => array_merge( $common, [
                'heading'     => [ 'type' => 'string',  'default' => __( 'Related Posts', 'prorank-seo' ) ],
                'withExcerpt' => [ 'type' => 'boolean', 'default' => false ],
            ] ),
            'editor_script'   => 'prorank-seo-related-posts-block',
            'category'        => 'prorank-seo',
            'title'           => __( 'ProRank Related Posts', 'prorank-seo' ),
            'description'     => __( 'Lists internally-relevant posts based on prominent words, with category/tag fallback.', 'prorank-seo' ),
            'icon'            => 'admin-links',
            'keywords'        => [ 'related', 'internal links', 'prorank-seo' ],
            'supports'        => [ 'html' => false, 'align' => true ],
        ] );

        register_block_type( self::BLOCK_SIBLINGS, [
            'api_version'     => 3,
            'render_callback' => [ $this, 'render_sibling_pages' ],
            'attributes'      => array_merge( $common, [
                'heading' => [ 'type' => 'string', 'default' => __( 'Other Pages in This Section', 'prorank-seo' ) ],
            ] ),
            'editor_script'   => 'prorank-seo-sibling-pages-block',
            'category'        => 'prorank-seo',
            'title'           => __( 'ProRank Sibling Pages', 'prorank-seo' ),
            'description'     => __( 'Lists other published pages under the same parent.', 'prorank-seo' ),
            'icon'            => 'admin-page',
            'keywords'        => [ 'siblings', 'pages', 'navigation' ],
            'supports'        => [ 'html' => false, 'align' => true ],
        ] );

        register_block_type( self::BLOCK_SUBPAGES, [
            'api_version'     => 3,
            'render_callback' => [ $this, 'render_subpages' ],
            'attributes'      => array_merge( $common, [
                'heading' => [ 'type' => 'string', 'default' => __( 'In This Section', 'prorank-seo' ) ],
            ] ),
            'editor_script'   => 'prorank-seo-subpages-block',
            'category'        => 'prorank-seo',
            'title'           => __( 'ProRank Subpages', 'prorank-seo' ),
            'description'     => __( 'Lists direct child pages of the current page.', 'prorank-seo' ),
            'icon'            => 'category',
            'keywords'        => [ 'subpages', 'children', 'navigation' ],
            'supports'        => [ 'html' => false, 'align' => true ],
        ] );
    }

    public function enqueue_editor_assets(): void {
        $defs = [
            'prorank-seo-related-posts-block'  => 'related-posts.js',
            'prorank-seo-sibling-pages-block'  => 'sibling-pages.js',
            'prorank-seo-subpages-block'       => 'subpages.js',
        ];
        foreach ( $defs as $handle => $file ) {
            $rel = 'build/blocks/' . $file;
            $path = ( defined( 'PRORANK_PLUGIN_DIR' ) ? PRORANK_PLUGIN_DIR : trailingslashit( plugin_dir_path( __DIR__ . '/../../../../' ) ) ) . $rel;
            if ( ! file_exists( $path ) ) {
                continue;
            }
            wp_enqueue_script(
                $handle,
                ( defined( 'PRORANK_PLUGIN_URL' ) ? PRORANK_PLUGIN_URL : '' ) . $rel,
                [
                    'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components',
                    'wp-i18n', 'wp-server-side-render',
                ],
                defined( 'PRORANK_SEO_VERSION' ) ? PRORANK_SEO_VERSION : '1.0.0',
                true
            );
        }
    }

    // -----------------------------------------------------------------
    // Render callbacks
    // -----------------------------------------------------------------

    public function render_related_posts( $attributes ): string {
        $post_id = (int) get_the_ID();
        if ( $post_id <= 0 ) {
            return '';
        }
        $max = $this->clamp_max( (int) ( $attributes['maxLinks'] ?? self::DEFAULT_MAX_LINKS ) );
        $ids = $this->find_related_post_ids( $post_id, $max );
        if ( empty( $ids ) ) {
            return '';
        }
        return $this->render_links_section(
            $attributes,
            $ids,
            ! empty( $attributes['withExcerpt'] ),
            'prorank-related-posts'
        );
    }

    public function render_sibling_pages( $attributes ): string {
        $post_id = (int) get_the_ID();
        if ( $post_id <= 0 ) {
            return '';
        }
        $post = get_post( $post_id );
        if ( ! $post || ( $post->post_type ?? '' ) !== 'page' ) {
            return '';
        }
        $parent_id = (int) ( $post->post_parent ?? 0 );
        if ( $parent_id <= 0 ) {
            return '';
        }
        $max = $this->clamp_max( (int) ( $attributes['maxLinks'] ?? self::DEFAULT_MAX_LINKS ) );
        $ids = $this->fetch_pages_by_parent( $parent_id, $post_id, $max );
        if ( empty( $ids ) ) {
            return '';
        }
        return $this->render_links_section( $attributes, $ids, false, 'prorank-sibling-pages' );
    }

    public function render_subpages( $attributes ): string {
        $post_id = (int) get_the_ID();
        if ( $post_id <= 0 ) {
            return '';
        }
        $post = get_post( $post_id );
        if ( ! $post || ( $post->post_type ?? '' ) !== 'page' ) {
            return '';
        }
        $max = $this->clamp_max( (int) ( $attributes['maxLinks'] ?? self::DEFAULT_MAX_LINKS ) );
        $ids = $this->fetch_pages_by_parent( $post_id, $post_id, $max );
        if ( empty( $ids ) ) {
            return '';
        }
        return $this->render_links_section( $attributes, $ids, false, 'prorank-subpages' );
    }

    // -----------------------------------------------------------------
    // Related-posts scoring
    // -----------------------------------------------------------------

    /**
     * Returns ordered post IDs for the related-posts block. PW-overlap first,
     * taxonomy overlap second, recent same-type third — bounded to $max.
     *
     * @return array<int,int>
     */
    public function find_related_post_ids( int $post_id, int $max ): array {
        $current = get_post( $post_id );
        if ( ! $current ) {
            return [];
        }
        $post_type = (string) ( $current->post_type ?? 'post' );

        $own_terms = $this->read_pw_terms( $post_id );
        $own_weights = [];
        foreach ( array_slice( $own_terms, 0, self::TOP_TERMS_USED ) as $row ) {
            $term = isset( $row['term'] ) ? strtolower( (string) $row['term'] ) : '';
            if ( $term === '' ) {
                continue;
            }
            $own_weights[ $term ] = (int) ( $row['weight'] ?? $row['count'] ?? 1 );
        }
        $has_pw = ! empty( $own_weights );

        $pool_ids = $this->fetch_candidate_pool( $post_id, $post_type, self::CANDIDATE_POOL_CAP );
        if ( empty( $pool_ids ) ) {
            return [];
        }
        // Prime postmeta cache once for the whole pool so per-id PW reads are free.
        if ( function_exists( 'update_postmeta_cache' ) ) {
            update_postmeta_cache( $pool_ids );
        }

        $scored = [];
        $fallback_pool = [];

        foreach ( $pool_ids as $id ) {
            $id = (int) $id;
            if ( ! $this->is_link_target_eligible( $id ) ) {
                continue;
            }
            $score = 0;
            if ( $has_pw ) {
                $other = $this->read_pw_terms( $id );
                foreach ( $other as $row ) {
                    $t = isset( $row['term'] ) ? strtolower( (string) $row['term'] ) : '';
                    if ( $t === '' || ! isset( $own_weights[ $t ] ) ) {
                        continue;
                    }
                    $score += $own_weights[ $t ] + (int) ( $row['weight'] ?? $row['count'] ?? 1 );
                }
            }
            if ( $score > 0 ) {
                $scored[] = [ 'id' => $id, 'score' => $score ];
            } else {
                $fallback_pool[] = $id;
            }
        }

        usort( $scored, static function ( $a, $b ) {
            if ( $a['score'] === $b['score'] ) {
                return $a['id'] <=> $b['id'];
            }
            return $b['score'] <=> $a['score'];
        } );

        $picked = [];
        foreach ( $scored as $row ) {
            $picked[ $row['id'] ] = true;
            if ( count( $picked ) >= $max ) {
                break;
            }
        }

        // R1 — Fallback A is post-type-aware.
        //
        // For PAGES the natural topical signal is the page tree (taxonomies
        // are usually empty on pages), so prefer same-parent siblings + the
        // direct children of the current page before generic recency.
        //
        // For everything else, keep the original category/tag overlap path.
        if ( count( $picked ) < $max ) {
            if ( $post_type === 'page' ) {
                $tree_ids = $this->same_tree_page_ids( $post_id, $current, $max );
                foreach ( $tree_ids as $id ) {
                    $picked[ (int) $id ] = true;
                    if ( count( $picked ) >= $max ) {
                        break;
                    }
                }
            } else {
                $taxo = $this->taxonomy_overlap_subset( $post_id, $fallback_pool );
                foreach ( $taxo as $id ) {
                    $picked[ (int) $id ] = true;
                    if ( count( $picked ) >= $max ) {
                        break;
                    }
                }
            }
        }

        // Fallback B — recent same post type (whatever is left in the pool,
        // already sorted modified DESC by the query).
        if ( count( $picked ) < $max ) {
            foreach ( $fallback_pool as $id ) {
                $picked[ (int) $id ] = true;
                if ( count( $picked ) >= $max ) {
                    break;
                }
            }
        }

        return array_slice( array_keys( $picked ), 0, $max );
    }

    /**
     * @return array<int,int>
     */
    private function fetch_candidate_pool( int $exclude_id, string $post_type, int $limit ): array {
        $query = new \WP_Query( [
            'post_type'        => $post_type,
            'post_status'      => 'publish',
            'post__not_in'     => [ $exclude_id ],
            'has_password'     => false,
            'posts_per_page'   => $limit,
            'orderby'          => 'modified',
            'order'            => 'DESC',
            'fields'           => 'ids',
            'no_found_rows'    => true,
            'ignore_sticky_posts' => true,
            'suppress_filters' => false,
        ] );
        $ids = (array) ( $query->posts ?? [] );
        return array_values( array_map( 'intval', $ids ) );
    }

    /**
     * Returns a subset of `$pool_ids` that share at least one term in the
     * post's primary public taxonomies. Used as Fallback A so non-PW posts
     * still get a topical signal.
     *
     * @return array<int,int>
     */
    private function taxonomy_overlap_subset( int $post_id, array $pool_ids ): array {
        if ( empty( $pool_ids ) ) {
            return [];
        }
        $taxonomies = function_exists( 'get_object_taxonomies' )
            ? (array) get_object_taxonomies( get_post_type( $post_id ), 'names' )
            : [ 'category', 'post_tag' ];
        $own_term_ids = [];
        foreach ( $taxonomies as $tax ) {
            $terms = function_exists( 'wp_get_post_terms' ) ? wp_get_post_terms( $post_id, $tax, [ 'fields' => 'ids' ] ) : [];
            if ( is_array( $terms ) ) {
                foreach ( $terms as $tid ) {
                    $own_term_ids[ (int) $tid ] = true;
                }
            }
        }
        if ( empty( $own_term_ids ) ) {
            return [];
        }
        $matches = [];
        foreach ( $pool_ids as $id ) {
            foreach ( $taxonomies as $tax ) {
                $terms = function_exists( 'wp_get_post_terms' ) ? wp_get_post_terms( (int) $id, $tax, [ 'fields' => 'ids' ] ) : [];
                if ( ! is_array( $terms ) ) {
                    continue;
                }
                foreach ( $terms as $tid ) {
                    if ( isset( $own_term_ids[ (int) $tid ] ) ) {
                        $matches[ (int) $id ] = true;
                        continue 2;
                    }
                }
            }
        }
        return array_keys( $matches );
    }

    /**
     * R1 — page-tree fallback used by `find_related_post_ids` when the
     * current post is a page and prominent-word matches did not fill the
     * slot count. Returns same-parent siblings first (ordered by
     * menu_order then title), then direct children of the current page.
     *
     * Excludes the current page, noindex / password-protected / non-
     * published pages, and de-duplicates IDs across the two query batches.
     * Caller still applies the final $max cap.
     *
     * @param int   $post_id  Current page ID.
     * @param mixed $current  Already-fetched current WP_Post.
     * @param int   $cap      Upper bound on how many IDs to return.
     * @return array<int,int>
     */
    private function same_tree_page_ids( int $post_id, $current, int $cap ): array {
        if ( $cap <= 0 ) {
            return [];
        }
        $parent_id = (int) ( $current->post_parent ?? 0 );
        $tree = [];

        // Siblings — pages sharing the same parent as the current page.
        // `fetch_pages_by_parent` already excludes the current page,
        // applies the eligibility gate, and orders by menu_order/title.
        if ( $parent_id >= 0 ) {
            foreach ( $this->fetch_pages_by_parent( $parent_id, $post_id, $cap ) as $id ) {
                $tree[ (int) $id ] = true;
                if ( count( $tree ) >= $cap ) {
                    return array_keys( $tree );
                }
            }
        }

        // Direct children of the current page — gives users on a section
        // hub a deeper second pass when sibling coverage is thin.
        foreach ( $this->fetch_pages_by_parent( $post_id, $post_id, $cap ) as $id ) {
            $tree[ (int) $id ] = true;
            if ( count( $tree ) >= $cap ) {
                break;
            }
        }
        return array_keys( $tree );
    }

    /**
     * Sibling / subpage query. Same query shape; the caller picks the parent.
     *
     * @return array<int,int>
     */
    private function fetch_pages_by_parent( int $parent_id, int $exclude_id, int $limit ): array {
        $query = new \WP_Query( [
            'post_type'      => 'page',
            'post_status'    => 'publish',
            'post_parent'    => $parent_id,
            'post__not_in'   => [ $exclude_id ],
            'has_password'   => false,
            'posts_per_page' => max( 1, min( self::CANDIDATE_POOL_CAP, $limit * 3 ) ),
            'orderby'        => [ 'menu_order' => 'ASC', 'title' => 'ASC' ],
            'fields'         => 'ids',
            'no_found_rows'  => true,
        ] );
        $ids = (array) ( $query->posts ?? [] );
        $out = [];
        foreach ( $ids as $id ) {
            $id = (int) $id;
            if ( $this->is_link_target_eligible( $id ) ) {
                $out[] = $id;
            }
            if ( count( $out ) >= $limit ) {
                break;
            }
        }
        return $out;
    }

    // -----------------------------------------------------------------
    // Markup
    // -----------------------------------------------------------------

    private function render_links_section( array $attributes, array $ids, bool $with_excerpt, string $variant_class ): string {
        $layout = ( ( $attributes['layout'] ?? 'list' ) === 'grid' ) ? 'grid' : 'list';
        $extra_class = isset( $attributes['className'] ) ? (string) $attributes['className'] : '';
        $wrapper_classes = trim( implode( ' ', array_filter( [
            'prorank-related-content',
            $variant_class,
            'prorank-related-content--' . $layout,
            $extra_class,
        ] ) ) );

        $heading = isset( $attributes['heading'] ) ? (string) $attributes['heading'] : '';
        $heading = wp_strip_all_tags( $heading );

        $items_html = '';
        $seen_urls = [];
        foreach ( $ids as $id ) {
            $id = (int) $id;
            $url = get_permalink( $id );
            if ( ! is_string( $url ) || $url === '' ) {
                continue;
            }
            $url_key = strtolower( untrailingslashit( $url ) );
            if ( isset( $seen_urls[ $url_key ] ) ) {
                continue;
            }
            $seen_urls[ $url_key ] = true;

            $title = (string) get_the_title( $id );
            if ( $title === '' ) {
                continue;  // Skip empty anchors — spec: no invalid empty anchors.
            }
            $excerpt_html = '';
            if ( $with_excerpt ) {
                $raw = function_exists( 'get_the_excerpt' ) ? (string) get_the_excerpt( $id ) : '';
                $clean = wp_strip_all_tags( $raw );
                $clean = trim( preg_replace( '/\s+/u', ' ', $clean ) ?? '' );
                if ( $clean !== '' ) {
                    if ( function_exists( 'wp_trim_words' ) ) {
                        $clean = wp_trim_words( $clean, 24, '…' );
                    }
                    $excerpt_html = '<p class="prorank-related-content__excerpt">' . esc_html( $clean ) . '</p>';
                }
            }
            $items_html .= sprintf(
                '<li class="prorank-related-content__item"><a class="prorank-related-content__link" href="%s">%s</a>%s</li>',
                esc_url( $url ),
                esc_html( $title ),
                $excerpt_html
            );
        }
        if ( $items_html === '' ) {
            return '';
        }
        $heading_html = $heading !== ''
            ? '<h3 class="prorank-related-content__heading">' . esc_html( $heading ) . '</h3>'
            : '';
        return sprintf(
            '<nav class="%s">%s<ul class="prorank-related-content__list">%s</ul></nav>',
            esc_attr( $wrapper_classes ),
            $heading_html,
            $items_html
        );
    }

    // -----------------------------------------------------------------
    // Helpers
    // -----------------------------------------------------------------

    private function clamp_max( int $value ): int {
        if ( $value < self::MIN_MAX_LINKS ) {
            return self::MIN_MAX_LINKS;
        }
        if ( $value > self::MAX_MAX_LINKS ) {
            return self::MAX_MAX_LINKS;
        }
        return $value;
    }

    /** Read the T8 prominent-words meta for a post (already sorted). */
    private function read_pw_terms( int $post_id ): array {
        $stored = get_post_meta( $post_id, '_prorank_pw_terms', true );
        return is_array( $stored ) ? $stored : [];
    }

    /**
     * Block-render-time eligibility check. Stricter than the PW indexer's
     * `is_post_eligible` (which is concerned with public post types), so we
     * inline the rules instead of cross-calling — keeps T10 self-contained.
     */
    public function is_link_target_eligible( int $post_id ): bool {
        if ( $post_id <= 0 ) {
            return false;
        }
        $post = get_post( $post_id );
        if ( ! $post ) {
            return false;
        }
        if ( ( $post->post_status ?? '' ) !== 'publish' ) {
            return false;
        }
        if ( ! empty( $post->post_password ) ) {
            return false;
        }
        if ( $this->is_noindex( $post_id ) ) {
            return false;
        }
        return true;
    }

    /**
     * Mirror of `ProminentWordsAnalyzer::post_is_noindex` — kept here so this
     * module does not introduce a hard dep on the T8 analyzer. The canonical
     * ProRank key is `_prorank_meta_robots` (array, may contain `noindex`).
     */
    private function is_noindex( int $post_id ): bool {
        $pr = get_post_meta( $post_id, '_prorank_meta_robots', true );
        if ( is_array( $pr ) && in_array( 'noindex', $pr, true ) ) {
            return true;
        }
        foreach ( [ '_prorank_seo_robots_noindex', '_prorank_robots_noindex' ] as $k ) {
            $v = get_post_meta( $post_id, $k, true );
            if ( $v === '1' || $v === 1 || $v === true || $v === 'true' ) {
                return true;
            }
        }
        $aliases = [ '_yoast_wpseo_meta-robots-noindex', 'rank_math_robots', '_aioseo_noindex' ];
        foreach ( $aliases as $k ) {
            $v = get_post_meta( $post_id, $k, true );
            if ( $v === '1' || $v === 1 ) {
                return true;
            }
            if ( is_array( $v ) && in_array( 'noindex', $v, true ) ) {
                return true;
            }
        }
        return false;
    }
}
