<?php

/**
 * Site Schema Collector
 *
 * Walks the site and collects structured-data entities into a single flat list,
 * ready to be deduped by EntityDeduplicator and serialised as a JSON-LD @graph.
 *
 * Phase 1 sources:
 *   - Site basics (WebSite, Organization or Person, logo ImageObject)
 *   - Each indexable post/page:
 *       - Saved `_prorank_schema_data` meta (preferred)
 *       - Fallback WebPage/Article node derived from post fields
 *   - Home page WebPage (for the site root)
 *
 * Out of scope for Phase 1:
 *   - Taxonomy archive pages
 *   - Third-party plugin hooks (Events Calendar, WP Recipe Maker, ...)
 *   - Author archive pages
 *   - Sitewide search action variant
 *
 * @package ProRank\SEO\Core\Schema
 * @since   2.2.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\Schema;

use WP_Post;

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


class SiteSchemaCollector {

    /**
     * Shared per-post entity builder.
     *
     * Owns all post-level construction logic (saved schema, default content
     * entity, description, WebPage, Person, noindex/eligibility checks,
     * indexable post types, publisher ref). Lazy-instantiated so this
     * collector can still be unit-tested without bootstrapping the builder.
     *
     * @var PostEntityBuilder|null
     */
    private ?PostEntityBuilder $entity_builder = null;

    /**
     * Resolve the shared entity builder.
     *
     * @return PostEntityBuilder
     */
    private function entity_builder(): PostEntityBuilder {
        if ($this->entity_builder === null) {
            $this->entity_builder = new PostEntityBuilder();
        }
        return $this->entity_builder;
    }

    /**
     * Delegates to PostEntityBuilder::publisher_entity_ref().
     *
     * Kept as a thin wrapper so call sites in this class stay readable and
     * any future site-collector-specific overrides have one place to hook.
     *
     * @return array|null
     */
    private function publisher_entity_ref(): ?array {
        return $this->entity_builder()->publisher_entity_ref();
    }

    /**
     * Collect every entity that should appear on a given page of the site schema map.
     *
     * The site/org/person nodes are emitted once on page 1 only; every page then
     * carries per-post entities for its slice of the post list. This keeps the
     * total_entities count honest for pagination.
     *
     * @param int $page     Page number (1-indexed).
     * @param int $per_page Posts per page (caller clamps).
     * @return array {
     *   entities:      flat list of entity arrays (pre-dedupe)
     *   total_posts:   total indexable posts on the site (for pagination)
     *   total_entities:approximate total entities the full walk would produce
     * }
     */
    public function collect(int $page = 1, int $per_page = 100): array {
        $page = max(1, $page);
        $per_page = max(1, min(500, $per_page));

        $entities = [];

        // Site-level nodes only on the first page — subsequent pages ship only
        // post entities so the @graph for the last page isn't padded.
        if ($page === 1) {
            foreach ($this->collect_site_level() as $node) {
                $entities[] = $node;
            }
        }

        [$post_entities, $total_posts] = $this->collect_post_entities($page, $per_page);
        foreach ($post_entities as $node) {
            $entities[] = $node;
        }

        $total_entities = $total_posts * 2 + $this->site_level_count();

        /**
         * Filter the collected entities before dedupe.
         *
         * Lets premium / third-party code inject extra nodes (events, recipes,
         * products) without parallel collectors. Payloads must be flat arrays.
         *
         * @param array $entities Flat list of entity arrays.
         * @param int   $page     Current page.
         * @param int   $per_page Items per page.
         */
        $entities = apply_filters('prorank_schema_map_entities', $entities, $page, $per_page);
        if (!is_array($entities)) {
            $entities = [];
        }

        /**
         * Filter the site-wide entity-count estimate after enrichment.
         *
         * The default estimate is computed pre-enrichment (total_posts * 2 +
         * site-level nodes). Enrichers that add entities at runtime should
         * hook this filter to contribute their site-wide delta so the
         * `total_entities_estimate` meta field stays honest.
         *
         * @param int $total_entities Current estimate.
         * @param int $page           Current page.
         * @param int $per_page       Items per page.
         * @param int $total_posts    Total indexable posts.
         */
        $total_entities = (int) apply_filters(
            'prorank_schema_map_total_entities_estimate',
            $total_entities,
            $page,
            $per_page,
            $total_posts
        );

        return [
            'entities' => $entities,
            'total_posts' => $total_posts,
            'total_entities' => $total_entities,
        ];
    }

    /**
     * Count the site-level nodes we expect to emit on page 1.
     *
     * @return int
     */
    private function site_level_count(): int {
        $site_basics = get_option('prorank_seo_site_basics', []);
        $has_representation = is_array($site_basics)
            && isset($site_basics['org_or_person'])
            && in_array($site_basics['org_or_person'], ['organization', 'person'], true);
        return $has_representation ? 3 : 2;
    }

    /**
     * Collect the site-level entities (WebSite, Organization/Person, logo ImageObject).
     *
     * @return array
     */
    private function collect_site_level(): array {
        $nodes = [];
        $site_url = EntityIdBuilder::site_base_url();
        $name = get_bloginfo('name');
        $tagline = get_bloginfo('description');
        $site_basics = get_option('prorank_seo_site_basics', []);
        $site_basics = is_array($site_basics) ? $site_basics : [];

        // WebSite
        $website = [
            '@type' => 'WebSite',
            '@id' => EntityIdBuilder::website(),
            'url' => $site_url,
            'name' => $name,
        ];
        if (!empty($tagline)) {
            $website['description'] = $tagline;
        }
        // Link site → publisher (Organization or Person) when configured.
        // Resolved via publisher_entity_ref() so WebSite and every content
        // entity share the same single source of truth for the publisher @id.
        $publisher_ref = $this->publisher_entity_ref();
        if ($publisher_ref !== null) {
            $website['publisher'] = $publisher_ref;
        }
        $nodes[] = $website;

        // Organization or Person
        $representation = $site_basics['org_or_person'] ?? '';
        if ($representation === 'organization') {
            $nodes[] = $this->build_organization_node($site_basics, $name, $tagline);
        } elseif ($representation === 'person') {
            $nodes[] = $this->build_person_node($site_basics, $name);
        }

        // Logo ImageObject (shared across any node that has a logo URL)
        $logo_url = $this->extract_logo_url($site_basics);
        if ($logo_url !== '') {
            $nodes[] = [
                '@type' => 'ImageObject',
                '@id' => EntityIdBuilder::site_logo(),
                'url' => $logo_url,
                'contentUrl' => $logo_url,
            ];
        }

        return $nodes;
    }

    /**
     * Build the site-level Organization entity.
     *
     * @param array  $site_basics Site basics option.
     * @param string $name        Fallback site name.
     * @param string $tagline     Fallback site tagline.
     * @return array
     */
    private function build_organization_node(array $site_basics, string $name, string $tagline): array {
        $schema = [
            '@type' => 'Organization',
            '@id' => EntityIdBuilder::organization(),
            'url' => EntityIdBuilder::site_base_url(),
        ];

        $org_name = $site_basics['org_name'] ?? ($site_basics['site_name'] ?? $name);
        if (!empty($org_name)) {
            $schema['name'] = $org_name;
        }
        if (!empty($site_basics['org_alternate_name'])) {
            $schema['alternateName'] = $site_basics['org_alternate_name'];
        } elseif (!empty($site_basics['site_alternate_name'])) {
            $schema['alternateName'] = $site_basics['site_alternate_name'];
        }
        $description = $site_basics['org_description'] ?? ($site_basics['site_tagline'] ?? $tagline);
        if (!empty($description)) {
            $schema['description'] = $description;
        }
        $logo_url = $this->extract_logo_url($site_basics);
        if ($logo_url !== '') {
            $schema['logo'] = ['@id' => EntityIdBuilder::site_logo()];
        }

        // Contact
        if (!empty($site_basics['org_email']) || !empty($site_basics['org_phone'])) {
            $contact = ['@type' => 'ContactPoint', 'contactType' => 'customer service'];
            if (!empty($site_basics['org_email'])) {
                $contact['email'] = $site_basics['org_email'];
            }
            if (!empty($site_basics['org_phone'])) {
                $contact['telephone'] = $site_basics['org_phone'];
            }
            $schema['contactPoint'] = $contact;
        }

        // Social profiles
        $same_as = $this->collect_social_profiles($site_basics);
        if (!empty($same_as)) {
            $schema['sameAs'] = array_values($same_as);
        }

        return $schema;
    }

    /**
     * Build the site-level Person entity.
     *
     * @param array  $site_basics Site basics option.
     * @param string $site_name   Fallback name.
     * @return array
     */
    private function build_person_node(array $site_basics, string $site_name): array {
        $user_id = isset($site_basics['person_user_id']) ? (int) $site_basics['person_user_id'] : 0;

        $schema = [
            '@type' => 'Person',
            '@id' => EntityIdBuilder::person($user_id),
            'url' => EntityIdBuilder::site_base_url(),
        ];

        if ($user_id > 0) {
            $user = get_user_by('id', $user_id);
            if ($user && !empty($user->display_name)) {
                $schema['name'] = $user->display_name;
            }
        }
        if (empty($schema['name']) && !empty($site_basics['site_name'])) {
            $schema['name'] = $site_basics['site_name'];
        }
        if (empty($schema['name'])) {
            $schema['name'] = $site_name;
        }

        if (!empty($site_basics['person_logo'])) {
            $schema['image'] = $site_basics['person_logo'];
        }

        $same_as = $this->collect_social_profiles($site_basics);
        if (!empty($same_as)) {
            $schema['sameAs'] = array_values($same_as);
        }

        return $schema;
    }

    /**
     * Extract a usable logo URL from the site basics option.
     *
     * @param array $site_basics Site basics option value.
     * @return string
     */
    private function extract_logo_url(array $site_basics): string {
        foreach (['org_logo', 'site_logo', 'person_logo'] as $key) {
            if (!empty($site_basics[$key]) && is_string($site_basics[$key])) {
                return esc_url_raw($site_basics[$key]);
            }
        }
        return '';
    }

    /**
     * Collect public social profile URLs for sameAs.
     *
     * @param array $site_basics Site basics option value.
     * @return array
     */
    private function collect_social_profiles(array $site_basics): array {
        $keys = [
            'social_facebook', 'social_twitter', 'social_instagram', 'social_linkedin',
            'social_youtube', 'social_pinterest', 'social_tiktok', 'social_github',
            'social_mastodon', 'social_threads',
        ];
        $profiles = [];
        foreach ($keys as $key) {
            if (!empty($site_basics[$key]) && is_string($site_basics[$key])) {
                $url = trim($site_basics[$key]);
                if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) {
                    $profiles[] = esc_url_raw($url);
                }
            }
        }
        return $profiles;
    }

    /**
     * Collect per-post entities for a single page of the site.
     *
     * Returns pairs of (WebPage, content-entity) per post, plus a total post count
     * for pagination.
     *
     * @param int $page     Page number.
     * @param int $per_page Posts per page.
     * @return array{0: array, 1: int} Tuple of entities and total post count.
     */
    private function collect_post_entities(int $page, int $per_page): array {
        $post_types = $this->indexable_post_types();
        if (empty($post_types)) {
            return [[], 0];
        }

        $query_args = [
            'post_type' => $post_types,
            'post_status' => 'publish',
            'posts_per_page' => $per_page,
            'paged' => $page,
            'orderby' => 'ID',
            'order' => 'ASC',
            'no_found_rows' => false,
            'update_post_term_cache' => false,
            'suppress_filters' => false,
            'ignore_sticky_posts' => true,
        ];

        /**
         * Filter the query args used by the schema collector.
         *
         * @param array $query_args WP_Query args.
         * @param int   $page       Current page.
         * @param int   $per_page   Page size.
         */
        $query_args = apply_filters('prorank_schema_map_query_args', $query_args, $page, $per_page);

        $query = new \WP_Query($query_args);
        $entities = [];

        if ($query->have_posts()) {
            $builder = $this->entity_builder();
            foreach ($query->posts as $post) {
                if (!($post instanceof WP_Post)) {
                    continue;
                }
                foreach ($builder->build_for_post($post) as $node) {
                    $entities[] = $node;
                }
            }
        }

        $total = (int) $query->found_posts;
        wp_reset_postdata();

        return [$entities, $total];
    }

    /**
     * Delegates to PostEntityBuilder::indexable_post_types().
     *
     * Thin wrapper so existing call sites in this class keep reading
     * `$this->indexable_post_types()`. All post-type gating now lives in
     * the builder.
     *
     * @return string[]
     */
    private function indexable_post_types(): array {
        return $this->entity_builder()->indexable_post_types();
    }
}
