<?php

/**
 * Post Entity Builder
 *
 * Single source of truth for building schema.org entities from a WP_Post.
 *
 * Extracted from SiteSchemaCollector so the same per-post construction can
 * be reused by future consumers (a `/ask` / NLWeb endpoint, per-post preview
 * tooling, etc.) without duplicating the collector's internals or coupling
 * them to the site-wide walk.
 *
 * Public surface:
 *
 *   build_for_post(WP_Post)              → list of entities (WebPage,
 *                                           primary content, Person, ...)
 *   build_primary_content_entity(WP_Post) → just the Article/Product/etc
 *   build_webpage_entity(WP_Post)        → just the WebPage node
 *   build_description(WP_Post)           → clean truncated description
 *   is_post_eligible(WP_Post)            → eligibility check
 *   indexable_post_types()               → filtered post-type list
 *
 * Each public method is deterministic for a given post at a given state of
 * the DB + site basics. No caching is done here — callers cache as they see
 * fit (the SiteSchemaCollector pairs with SchemaMapCache upstream).
 *
 * @package ProRank\SEO\Core\Schema
 * @since   2.3.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\Schema;

use WP_Post;

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


class PostEntityBuilder {

    /**
     * Memoized publisher @id reference.
     *
     * `false` = not computed yet, `null` = not configured, `array` = ref.
     *
     * @var array|false|null
     */
    private $publisher_ref_cache = false;

    /**
     * Memoized indexable post-type list.
     *
     * @var string[]|null
     */
    private ?array $indexable_post_types_cache = null;

    /**
     * Build every entity a single post contributes to the graph.
     *
     * Returns a flat list — typically `[WebPage, content_entity, Person]`
     * but can contain more when the saved schema payload is a Custom
     * `@graph` of multiple entities. Returns an empty list when the post
     * is ineligible (trashed, noindex, non-indexable type, no permalink).
     *
     * The first entity is ALWAYS the WebPage. The second is the primary
     * content entity (Article, BlogPosting, Product, FAQPage, ...).
     *
     * @param WP_Post $post Post object.
     * @return array
     */
    public function build_for_post( WP_Post $post ): array {
        if ( ! $this->is_post_eligible( $post ) ) {
            return [];
        }

        $permalink = (string) get_permalink( $post );
        if ( $permalink === '' ) {
            return [];
        }

        $webpage = $this->build_webpage_entity_internal( $post, $permalink );

        $content_entities = $this->load_saved_schema( $post );
        if ( $content_entities === null ) {
            $content_entities = [ $this->default_content_schema( $post, $permalink ) ];
        }

        $author_id = (int) $post->post_author;
        $webpage_id = $webpage['@id'];
        $stamped = $this->stamp_content_entities( $content_entities, $post, $webpage_id, $author_id );

        $entities = array_merge( [ $webpage ], $stamped );

        // Emit the author as a first-class Person node so cross-post dedupe
        // collapses multiple articles by the same author onto one identity.
        if ( $author_id > 0 ) {
            $entities[] = $this->build_person_entity( $author_id );
        }

        return $entities;
    }

    /**
     * Return just the primary content entity for a post (no WebPage, no Person).
     *
     * Useful for query-response paths where we want a single schema.org
     * record matching a post (e.g. the `schema_object` field of an NLWeb
     * /ask result). Respects the same eligibility rules as build_for_post().
     *
     * @param WP_Post $post Post object.
     * @return array|null
     */
    public function build_primary_content_entity( WP_Post $post ): ?array {
        $entities = $this->build_for_post( $post );
        if ( count( $entities ) < 2 ) {
            return null;
        }
        // Index 0 is the WebPage, 1 is the primary content entity.
        return $entities[1] ?? null;
    }

    /**
     * Return just the WebPage node for a post.
     *
     * @param WP_Post $post Post object.
     * @return array|null
     */
    public function build_webpage_entity( WP_Post $post ): ?array {
        if ( ! $this->is_post_eligible( $post ) ) {
            return null;
        }
        $permalink = (string) get_permalink( $post );
        if ( $permalink === '' ) {
            return null;
        }
        return $this->build_webpage_entity_internal( $post, $permalink );
    }

    /**
     * Build a clean, truncated description for a post.
     *
     * Order of preference: SEO meta description → post excerpt → content.
     * Strips HTML, collapses whitespace, truncates to 220 chars (mb-safe).
     * Returns an empty string when no sensible text is available.
     *
     * @param WP_Post $post Post object.
     * @return string
     */
    public function build_description( WP_Post $post ): string {
        $meta = get_post_meta( $post->ID, '_prorank_seo_description', true );
        if ( is_string( $meta ) && $meta !== '' ) {
            return wp_strip_all_tags( $meta );
        }
        $excerpt = trim( (string) get_the_excerpt( $post ) );
        if ( $excerpt !== '' ) {
            return wp_strip_all_tags( $excerpt );
        }
        $content = wp_strip_all_tags( (string) $post->post_content );
        $content = preg_replace( '/\s+/u', ' ', (string) $content );
        $content = is_string( $content ) ? trim( $content ) : '';
        if ( $content === '' ) {
            return '';
        }
        return function_exists( 'mb_substr' )
            ? (string) mb_substr( $content, 0, 220 )
            : substr( $content, 0, 220 );
    }

    /**
     * Whether a post qualifies to contribute to the schema graph.
     *
     * Consolidates every collector/enricher rule in one place:
     *   - real WP_Post
     *   - post_status = 'publish'
     *   - post_type in the filtered indexable list
     *   - not noindexed (honours legacy boolean meta + modern array/string meta)
     *
     * @param WP_Post|int $post Post or ID.
     * @return bool
     */
    public function is_post_eligible( $post ): bool {
        $post = $post instanceof WP_Post ? $post : get_post( (int) $post );
        if ( ! $post instanceof WP_Post ) {
            return false;
        }
        if ( $post->post_status !== 'publish' ) {
            return false;
        }
        if ( ! in_array( $post->post_type, $this->indexable_post_types(), true ) ) {
            return false;
        }
        if ( $this->is_post_noindexed( $post ) ) {
            return false;
        }
        return true;
    }

    /**
     * Return the list of public post types the schema-map considers indexable.
     *
     * Matches SiteSchemaCollector's filter contract exactly — applies the
     * `prorank_schema_map_post_types` filter so a single filter customises
     * both the collector's walk and the builder's eligibility check.
     *
     * Memoized per instance. New instances re-evaluate the filter chain.
     *
     * @return string[]
     */
    public function indexable_post_types(): array {
        if ( $this->indexable_post_types_cache !== null ) {
            return $this->indexable_post_types_cache;
        }

        $types = get_post_types( [ 'public' => true, 'exclude_from_search' => false ], 'names' );
        unset( $types['attachment'] );
        $types = array_values( $types );

        /** This filter is shared with the collector and premium enricher. */
        $filtered = apply_filters( 'prorank_schema_map_post_types', $types );
        $this->indexable_post_types_cache = is_array( $filtered )
            ? array_values( array_filter( $filtered, 'is_string' ) )
            : $types;
        return $this->indexable_post_types_cache;
    }

    /**
     * Publisher `{@id: ...}` reference from site basics (or null if unset).
     *
     * Exposed publicly so other consumers (admin tooling, /ask result
     * framing) can reference the same publisher identity the collector
     * uses. Memoized.
     *
     * @return array|null
     */
    public function publisher_entity_ref(): ?array {
        if ( $this->publisher_ref_cache !== false ) {
            return $this->publisher_ref_cache;
        }

        $site_basics = get_option( 'prorank_seo_site_basics', [] );
        if ( ! is_array( $site_basics ) || empty( $site_basics['org_or_person'] ) ) {
            $this->publisher_ref_cache = null;
            return null;
        }

        if ( $site_basics['org_or_person'] === 'organization' ) {
            $this->publisher_ref_cache = [ '@id' => EntityIdBuilder::organization() ];
        } elseif ( $site_basics['org_or_person'] === 'person' ) {
            $user_id = isset( $site_basics['person_user_id'] ) ? (int) $site_basics['person_user_id'] : 0;
            $this->publisher_ref_cache = [ '@id' => EntityIdBuilder::person( $user_id ) ];
        } else {
            $this->publisher_ref_cache = null;
        }

        return $this->publisher_ref_cache;
    }

    /*
     * ─────────────────────── internals ───────────────────────
     */

    /**
     * Build the WebPage node for a post.
     *
     * @param WP_Post $post       Post object.
     * @param string  $permalink  Post permalink (pre-resolved so we don't call it twice).
     * @return array
     */
    private function build_webpage_entity_internal( WP_Post $post, string $permalink ): array {
        $title = get_the_title( $post );
        $description = $this->build_description( $post );

        $webpage = [
            '@type' => 'WebPage',
            '@id' => EntityIdBuilder::webpage( $post->ID ),
            'url' => $permalink,
            'name' => $title,
            'isPartOf' => [ '@id' => EntityIdBuilder::website() ],
            'datePublished' => get_the_date( 'c', $post ),
            'dateModified' => get_the_modified_date( 'c', $post ),
        ];
        if ( $description !== '' ) {
            $webpage['description'] = $description;
        }
        return $webpage;
    }

    /**
     * Stamp post-scoped @ids, isPartOf, mainEntityOfPage, author, publisher
     * onto the list of content entities for a post.
     *
     * Done here rather than inside load_saved_schema() so anonymous custom
     * @graph children inherit post context (otherwise multiple posts with
     * anonymous nodes would collapse during site-wide dedupe).
     *
     * @param array  $entities    Content entities as returned by load_saved_schema / default_content_schema.
     * @param WP_Post $post       Post object.
     * @param string $webpage_id  WebPage @id for this post.
     * @param int    $author_id   Post author user ID.
     * @return array
     */
    private function stamp_content_entities( array $entities, WP_Post $post, string $webpage_id, int $author_id ): array {
        $stamped = [];
        foreach ( $entities as $index => $entity ) {
            if ( ! is_array( $entity ) || empty( $entity ) ) {
                continue;
            }

            // Strip per-entity @context — outer payload owns it.
            unset( $entity['@context'] );

            // Stamp a post-scoped @id on anonymous nodes.
            if ( ! isset( $entity['@id'] ) || ! is_string( $entity['@id'] ) || $entity['@id'] === '' ) {
                $type_hint = isset( $entity['@type'] ) && is_string( $entity['@type'] )
                    ? strtolower( $entity['@type'] )
                    : 'article';
                $suffix = $type_hint . ( $index > 0 ? '-' . $index : '' );
                $entity['@id'] = EntityIdBuilder::content( $post->ID, $suffix );
            }

            // Primary entity gets the relationship wiring.
            if ( $index === 0 ) {
                if ( ! isset( $entity['isPartOf'] ) ) {
                    $entity['isPartOf'] = [ '@id' => $webpage_id ];
                }
                if ( ! isset( $entity['mainEntityOfPage'] ) ) {
                    $entity['mainEntityOfPage'] = [ '@id' => $webpage_id ];
                }
                if ( $author_id > 0 && ! isset( $entity['author'] ) ) {
                    $entity['author'] = [ '@id' => EntityIdBuilder::person( $author_id ) ];
                }
                if ( ! isset( $entity['publisher'] ) ) {
                    $publisher_ref = $this->publisher_entity_ref();
                    if ( $publisher_ref !== null ) {
                        $entity['publisher'] = $publisher_ref;
                    }
                }
            }

            $stamped[] = $entity;
        }
        return $stamped;
    }

    /**
     * Load saved schema entities from `_prorank_schema_data` post meta.
     *
     * Returns a flat list. For Custom JSON-LD with an `@graph` wrapper,
     * flattens the children here (while the post is in scope) so the
     * downstream stamper can give each child a post-scoped @id.
     *
     * Returns null when no saved schema is present so callers can fall
     * back to default_content_schema().
     *
     * @param WP_Post $post Post object.
     * @return array|null
     */
    private function load_saved_schema( WP_Post $post ): ?array {
        $saved = get_post_meta( $post->ID, '_prorank_schema_data', true );
        if ( ! is_array( $saved ) || empty( $saved ) ) {
            return null;
        }

        $type = isset( $saved['type'] ) && is_string( $saved['type'] ) ? $saved['type'] : '';
        if ( $type === '' ) {
            return null;
        }

        if ( $type === 'Custom' ) {
            $decoded = json_decode( (string) ( $saved['custom_json'] ?? '' ), true );
            if ( ! is_array( $decoded ) || empty( $decoded ) ) {
                return null;
            }
            if ( isset( $decoded['@graph'] ) && is_array( $decoded['@graph'] ) ) {
                $flat = [];
                foreach ( $decoded['@graph'] as $child ) {
                    if ( is_array( $child ) && ! empty( $child ) ) {
                        $flat[] = $child;
                    }
                }
                return $flat !== [] ? $flat : null;
            }
            return [ $decoded ];
        }

        $schema = [
            '@type' => $type,
            'name' => get_the_title( $post ),
            'url' => get_permalink( $post ),
            'datePublished' => get_the_date( 'c', $post ),
            'dateModified' => get_the_modified_date( 'c', $post ),
        ];

        if ( ! empty( $saved['properties'] ) && is_array( $saved['properties'] ) ) {
            foreach ( $saved['properties'] as $key => $value ) {
                $property_key = sanitize_key( (string) $key );
                if ( $property_key === '' || $value === '' || $value === null ) {
                    continue;
                }
                $schema[ $property_key ] = is_scalar( $value ) ? $value : $value;
            }
        }

        return [ $schema ];
    }

    /**
     * Build the fallback primary content entity when no saved schema exists.
     *
     * @param WP_Post $post      Post object.
     * @param string  $permalink Permalink.
     * @return array
     */
    private function default_content_schema( WP_Post $post, string $permalink ): array {
        $type = $this->default_type_for_post( $post );
        $title = get_the_title( $post );
        $description = $this->build_description( $post );

        $schema = [
            '@type' => $type,
            'headline' => $title,
            'url' => $permalink,
            'datePublished' => get_the_date( 'c', $post ),
            'dateModified' => get_the_modified_date( 'c', $post ),
        ];
        if ( $description !== '' ) {
            $schema['description'] = $description;
        }
        $thumbnail_id = get_post_thumbnail_id( $post );
        if ( $thumbnail_id ) {
            $image = wp_get_attachment_image_src( $thumbnail_id, 'full' );
            if ( is_array( $image ) ) {
                $schema['image'] = [
                    '@type' => 'ImageObject',
                    'url' => $image[0],
                    'width' => $image[1],
                    'height' => $image[2],
                ];
            }
        }
        return $schema;
    }

    /**
     * Map post_type to a default schema @type.
     *
     * @param WP_Post $post Post object.
     * @return string
     */
    private function default_type_for_post( WP_Post $post ): string {
        switch ( $post->post_type ) {
            case 'page':
                return 'WebPage';
            case 'product':
                return 'Product';
            case 'post':
            default:
                return 'Article';
        }
    }

    /**
     * Build the standalone Person entity for a post author.
     *
     * @param int $author_id WP user ID.
     * @return array
     */
    private function build_person_entity( int $author_id ): array {
        $node = [
            '@type' => 'Person',
            '@id' => EntityIdBuilder::person( $author_id ),
            'name' => get_the_author_meta( 'display_name', $author_id ),
            'url' => get_author_posts_url( $author_id ),
        ];
        $bio = get_the_author_meta( 'description', $author_id );
        if ( is_string( $bio ) ) {
            $bio = trim( wp_strip_all_tags( $bio ) );
            if ( $bio !== '' ) {
                $node['description'] = $bio;
            }
        }
        return $node;
    }

    /**
     * Whether a post carries a noindex directive.
     *
     * Mirrors BaseSitemapProvider::is_post_explicitly_noindexed() — see
     * comment in the original collector extraction.
     *
     * @param WP_Post $post Post object.
     * @return bool
     */
    private function is_post_noindexed( WP_Post $post ): bool {
        foreach ( [ '_prorank_seo_noindex', '_prorank_seo_robots_noindex', '_prorank_robots_noindex' ] as $meta_key ) {
            $value = get_post_meta( $post->ID, $meta_key, true );
            if ( in_array( (string) $value, [ '1', 'true', 'yes' ], true ) ) {
                return true;
            }
        }

        $meta_robots = get_post_meta( $post->ID, '_prorank_meta_robots', true );
        if ( is_array( $meta_robots ) ) {
            return in_array( 'noindex', array_map( 'strval', $meta_robots ), true );
        }

        if ( is_string( $meta_robots ) && $meta_robots !== '' ) {
            return str_contains( strtolower( $meta_robots ), 'noindex' );
        }

        return false;
    }
}
