<?php

namespace WPSN\SEO;

use WPSN\Core\Logger;
use WPSN\Core\Settings;
use WPSN\Domain\SeoDocumentContext;
use WPSN\Domain\SeoMeta;
use WPSN\SEO\BreadcrumbsBuilder;

defined('ABSPATH') || exit;

/**
 * Build a JSON-LD graph for the current document.
 *
 * Stage 5 MVP:
 *  - WebSite
 *  - Organization
 *  - BreadcrumbList
 *  - BlogPosting ("Article") for posts
 *  - Product for WooCommerce products (optional)
 */
final class SchemaGraphBuilder
{
    private Settings $settings;
    private Logger $logger;

    private BreadcrumbsBuilder $breadcrumbs;

    private SchemaCustomJsonLdResolver $customJsonLd;

    public function __construct(Settings $settings, Logger $logger, BreadcrumbsBuilder $breadcrumbs, SchemaCustomJsonLdResolver $customJsonLd)
    {
        $this->settings = $settings;
        $this->logger   = $logger;
        $this->breadcrumbs = $breadcrumbs;
        $this->customJsonLd = $customJsonLd;
    }

    /**
     * Build JSON-LD output.
     *
     * @return array<string, mixed>|null
     */
    public function build(SeoDocumentContext $ctx, SeoMeta $meta): ?array
    {
        \WPSN\Core\PerfProbe::start('schema_builder');
        // Never output on 404.
        if ($ctx->type === SeoDocumentContext::TYPE_404) {
            \WPSN\Core\PerfProbe::stop('schema_builder');
            return null;
        }

        $canonical = is_string($meta->canonical) && trim($meta->canonical) !== ''
            ? trim($meta->canonical)
            : $ctx->url;

        if (!is_string($canonical) || trim($canonical) === '') {
            \WPSN\Core\PerfProbe::stop('schema_builder');
            return null;
        }

        $pageSchema = $this->resolveSchemaType($ctx);
        if ($pageSchema === 'none') {
            \WPSN\Core\PerfProbe::stop('schema_builder');
            return null;
        }

        // If WooCommerce product schema is disabled (or WooCommerce isn't active), fall back to WebPage.
        if ($pageSchema === 'product') {
            $wcModuleEnabled = (bool) $this->settings->moduleEnabled('woocommerce', true);
            $wcSchemaEnabled = (bool) $this->settings->get('woocommerce.schema.enabled', true);
            if (!$wcModuleEnabled || !$wcSchemaEnabled || !function_exists('wc_get_product') || !class_exists('WooCommerce')) {
                $pageSchema = 'webpage';
            }
        }

        $siteUrl = home_url('/');
        $siteUrl = is_string($siteUrl) ? $siteUrl : '';
        if ($siteUrl === '') {
            \WPSN\Core\PerfProbe::stop('schema_builder');
            return null;
        }

        $siteUrlNoSlash = rtrim($siteUrl, '/');

        $orgId     = $siteUrlNoSlash . '#organization';
        $websiteId = $siteUrlNoSlash . '#website';

        $graph = [];

        $orgNode = $this->buildOrganizationNode($orgId, $siteUrl);
        if (is_array($orgNode)) {
            $graph[] = $orgNode;
        }

        $graph[] = $this->buildWebSiteNode($websiteId, $siteUrl, $orgId);
        // Author SEO: add a Person node for post authors and author archives (optional module).
        $personId = '';
        if ((bool) $this->settings->moduleEnabled('author_seo', true)) {
            $userId = 0;

            // Author archive.
            if ($ctx->type === SeoDocumentContext::TYPE_ARCHIVE && $ctx->objectSubType === 'author' && $ctx->objectId !== null) {
                $userId = (int) $ctx->objectId;
            }

            // Singular article: use post author.
            if ($userId <= 0 && $ctx->isSingular() && $pageSchema === 'article') {
                $post = get_post((int) $ctx->objectId);
                if ($post instanceof \WP_Post) {
                    $userId = (int) $post->post_author;
                }
            }

            if ($userId > 0) {
                $personNode = $this->buildPersonNode($userId, is_array($orgNode) ? $orgId : '');
                if (is_array($personNode)) {
                    $pid = (string) ($personNode['@id'] ?? '');
                    if ($pid !== '') {
                        $personId = $pid;
                        $graph[] = $personNode;
                    }
                }
            }
        }


        $breadcrumbNode = $this->buildBreadcrumbListNode($ctx, $canonical, $siteUrl);
        if (is_array($breadcrumbNode)) {
            $graph[] = $breadcrumbNode;
        }

        $breadcrumbId = is_array($breadcrumbNode) ? (string) ($breadcrumbNode['@id'] ?? '') : '';
        $webPageNode = $this->buildWebPageNode($ctx, $meta, $canonical, $websiteId, $breadcrumbId);
        if (is_array($webPageNode)) {
            // Connect Product pages to a Product entity.
            if ($ctx->isSingular() && $pageSchema === 'product') {
                $webPageNode['mainEntity'] = [
                    '@id' => $canonical . '#product',
                ];
            }
            // Author archive: connect to the Person entity (ProfilePage mainEntity).
            if ($ctx->type === SeoDocumentContext::TYPE_ARCHIVE && $ctx->objectSubType === 'author' && $ctx->objectId !== null && $personId !== '') {
                $webPageNode['mainEntity'] = [
                    '@id' => $personId,
                ];
            }
            $graph[] = $webPageNode;
        }

        // "Article" (BlogPosting) for singular posts when enabled.
        if ($ctx->isSingular() && $pageSchema === 'article') {
            $articleNode = $this->buildBlogPostingNode($ctx, $meta, $canonical, $websiteId, $orgId, $personId);
            if (is_array($articleNode)) {
                $graph[] = $articleNode;
            }
        }

        // Product for WooCommerce products.
        if ($ctx->isSingular() && $pageSchema === 'product') {
            $productNode = $this->buildProductNode($ctx, $meta, $canonical, $websiteId, $orgId);
            if (is_array($productNode)) {
                $graph[] = $productNode;
            }
        }

        $custom = $this->customJsonLd->resolve($ctx);
        if (!empty($custom['errors'])) {
            foreach ($custom['errors'] as $error) {
                $this->logger->warning('schema_custom_json_ld_invalid', [
                    'source' => isset($error['source']) ? (string) $error['source'] : '',
                    'message' => isset($error['message']) ? (string) $error['message'] : '',
                ]);
            }
        }
        if (!empty($custom['nodes'])) {
            $graph = array_merge($graph, $custom['nodes']);
        }

        if (empty($graph)) {
            \WPSN\Core\PerfProbe::stop('schema_builder');
            return null;
        }

        $out = [
            '@context' => 'https://schema.org',
            '@graph'   => $graph,
        ];

        /**
         * Filter the final JSON-LD array.
         *
         * @since 0.5.0
         *
         * @param array<string, mixed> $out
         */
        $out = apply_filters('wpsn/schema/json_ld', $out, $ctx, $meta);
        $out = is_array($out) ? $out : null;
        \WPSN\Core\PerfProbe::stop('schema_builder');
        return $out;
    }

    private function resolveSchemaType(SeoDocumentContext $ctx): string
    {
        // Non-singular pages: output a generic WebPage/CollectionPage.
        if (!$ctx->isSingular()) {
            return 'webpage';
        }

        $raw = (string) get_post_meta((int) $ctx->objectId, MetaKeys::SCHEMA_TYPE, true);
        $raw = $raw !== '' ? trim($raw) : 'default';

        $allowed = ['default', 'none', 'article', 'webpage', 'product'];
        if (!in_array($raw, $allowed, true)) {
            $raw = 'default';
        }

        if ($raw === 'none') {
            return 'none';
        }
        if ($raw === 'article') {
            return 'article';
        }
        if ($raw === 'webpage') {
            return 'webpage';
        }
        if ($raw === 'product') {
            return 'product';
        }

        $pt = $ctx->objectSubType ?: (string) get_post_type((int) $ctx->objectId);
        $default = $this->defaultSchemaTypeForPostType($pt);
        if ($default !== '') {
            return $default;
        }

        // Default behavior.
        if ($pt === 'post') {
            return 'article';
        }
        if ($pt === 'product') {
            return 'product';
        }

        return 'webpage';
    }

    /**
     * @return array<string, mixed>|null
     */
    private function buildOrganizationNode(string $orgId, string $siteUrl): ?array
    {
        $name = (string) $this->settings->get('schema.org_name', '');
        $name = trim($name);
        if ($name === '') {
            $blogName = get_bloginfo('name');
            $name = is_string($blogName) ? trim($blogName) : '';
        }
        if ($name === '') {
            return null;
        }

        $logo = $this->resolveOrganizationLogo();
        $sameAs = $this->resolveOrganizationSameAs();
        $alternateName = $this->organizationSettingString('schema.org_alternate_name');
        $description = $this->organizationSettingString('schema.org_description');
        $email = $this->organizationSettingString('schema.org_email', 'local.business.email');
        $telephone = $this->organizationSettingString('schema.org_telephone', 'local.business.telephone');
        $legalName = $this->organizationSettingString('schema.org_legal_name');
        $foundingDate = $this->organizationSettingString('schema.org_founding_date');
        $vatId = $this->organizationSettingString('schema.org_vat_id');
        $taxId = $this->organizationSettingString('schema.org_tax_id');
        $iso6523Code = $this->organizationSettingString('schema.org_iso6523_code');
        $duns = $this->organizationSettingString('schema.org_duns');
        $leiCode = $this->organizationSettingString('schema.org_lei_code');
        $naics = $this->organizationSettingString('schema.org_naics');
        $numberEmployees = (int) $this->settings->get('schema.org_number_employees', 0);
        $policyUrls = $this->resolveOrganizationPolicyUrls();

        $node = [
            '@type' => 'Organization',
            '@id'   => $orgId,
            'name'  => $name,
            'url'   => $siteUrl,
        ];

        if ($alternateName !== '') {
            $node['alternateName'] = $alternateName;
        }

        if ($description !== '') {
            $node['description'] = $description;
        }

        if ($email !== '') {
            $node['email'] = $email;
        }

        if ($telephone !== '') {
            $node['telephone'] = $telephone;
        }

        if ($legalName !== '') {
            $node['legalName'] = $legalName;
        }

        if ($foundingDate !== '') {
            $node['foundingDate'] = $foundingDate;
        }

        if ($numberEmployees > 0) {
            $node['numberOfEmployees'] = [
                '@type' => 'QuantitativeValue',
                'value' => $numberEmployees,
            ];
        }

        if ($vatId !== '') {
            $node['vatID'] = $vatId;
        }

        if ($taxId !== '') {
            $node['taxID'] = $taxId;
        }

        if ($iso6523Code !== '') {
            $node['iso6523Code'] = $iso6523Code;
        }

        if ($duns !== '') {
            $node['duns'] = $duns;
        }

        if ($leiCode !== '') {
            $node['leiCode'] = $leiCode;
        }

        if ($naics !== '') {
            $node['naics'] = $naics;
        }

        foreach ($policyUrls as $property => $url) {
            $node[$property] = $url;
        }

        if (is_array($logo)) {
            $node['logo'] = $logo;
        }

        if (!empty($sameAs)) {
            $node['sameAs'] = $sameAs;
        }

        return $node;
    }

    /**
     * @return array<string, mixed>
     */
    private function buildWebSiteNode(string $websiteId, string $siteUrl, string $orgId): array
    {
        $siteName = get_bloginfo('name');
        $siteName = is_string($siteName) ? trim($siteName) : '';

        $node = [
            '@type' => 'WebSite',
            '@id'   => $websiteId,
            'url'   => $siteUrl,
            'name'  => $siteName !== '' ? $siteName : $siteUrl,
            'publisher' => [
                '@id' => $orgId,
            ],
            'potentialAction' => [
                '@type' => 'SearchAction',
                'target' => home_url('/?s={search_term_string}'),
                'query-input' => 'required name=search_term_string',
            ],
        ];

        $locale = get_locale();
        $locale = is_string($locale) ? str_replace('-', '_', $locale) : '';
        if ($locale !== '') {
            $node['inLanguage'] = $locale;
        }

        return $node;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function buildWebPageNode(SeoDocumentContext $ctx, SeoMeta $meta, string $canonical, string $websiteId, string $breadcrumbId): ?array
    {
        $type = 'WebPage';
        if ($ctx->type === SeoDocumentContext::TYPE_TERM || $ctx->type === SeoDocumentContext::TYPE_ARCHIVE) {
            $type = 'CollectionPage';
        }
        // Author archive: treat as a profile page.
        if ($ctx->type === SeoDocumentContext::TYPE_ARCHIVE && $ctx->objectSubType === 'author' && $ctx->objectId !== null) {
            $type = 'ProfilePage';
        }
        if ($ctx->type === SeoDocumentContext::TYPE_SEARCH) {
            $type = 'SearchResultsPage';
        }

        $title = is_string($meta->title) ? trim($meta->title) : '';
        if ($title === '') {
            $title = wp_get_document_title();
            $title = is_string($title) ? trim($title) : '';
        }

        $node = [
            '@type' => $type,
            '@id'   => $canonical . '#webpage',
            'url'   => $canonical,
            'name'  => $title !== '' ? $title : $canonical,
            'isPartOf' => [
                '@id' => $websiteId,
            ],
        ];

        $desc = is_string($meta->description) ? trim($meta->description) : '';
        if ($desc !== '') {
            $node['description'] = $desc;
        }

        if ($breadcrumbId !== '') {
            $node['breadcrumb'] = [
                '@id' => $breadcrumbId,
            ];
        }

        $locale = get_locale();
        $locale = is_string($locale) ? str_replace('-', '_', $locale) : '';
        if ($locale !== '') {
            $node['inLanguage'] = $locale;
        }

        return $node;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function buildBreadcrumbListNode(SeoDocumentContext $ctx, string $canonical, string $homeUrl): ?array
    {
        if (!(bool) $this->settings->get('breadcrumbs.schema_enabled', true)) {
            return null;
        }

        $crumbs = $this->breadcrumbs->build($ctx, $homeUrl);
        if (count($crumbs) < 2) {
            return null;
        }

        $elements = [];
        $pos = 1;
        foreach ($crumbs as $crumb) {
            if (empty($crumb['url']) || empty($crumb['name'])) {
                continue;
            }

            $elements[] = [
                '@type'    => 'ListItem',
                'position' => $pos,
                'item'     => [
                    '@id'  => (string) $crumb['url'],
                    'name' => (string) $crumb['name'],
                ],
            ];
            $pos++;
        }

        if (count($elements) < 2) {
            return null;
        }

        return [
            '@type' => 'BreadcrumbList',
            '@id'   => $canonical . '#breadcrumb',
            'itemListElement' => $elements,
        ];
    }

    /**
     * @return array<string, mixed>|null
     */
    private function buildBlogPostingNode(SeoDocumentContext $ctx, SeoMeta $meta, string $canonical, string $websiteId, string $orgId, string $authorPersonId = ''): ?array
    {
        $postId = (int) $ctx->objectId;
        $post = get_post($postId);
        if (!$post instanceof \WP_Post) {
            return null;
        }

        // Publisher details.
        $orgName = (string) $this->settings->get('schema.org_name', '');
        $orgName = trim($orgName);
        if ($orgName === '') {
            $bn = get_bloginfo('name');
            $orgName = is_string($bn) ? trim($bn) : '';
        }

        $logo = $this->resolveOrganizationLogo();
        if (!is_array($logo)) {
            // For Google Article rich results, publisher.logo is required.
            $this->logger->debug('Schema: publisher logo missing; skipping BlogPosting node.');
            return null;
        }

        // Author.
        $authorId = (int) $post->post_author;
        $authorName = get_the_author_meta('display_name', $authorId);
        $authorName = is_string($authorName) ? trim($authorName) : '';
        if ($authorName === '') {
            $authorName = __('Author', 'wp-seo-next');
        }

        $authorUrl = get_author_posts_url($authorId);
        $authorUrl = is_string($authorUrl) ? $authorUrl : '';

        $author = [];
        if ($authorPersonId !== '') {
            $author = [
                '@id' => $authorPersonId,
            ];
        } else {
            $author = [
                '@type' => 'Person',
                'name'  => $authorName,
            ];
            if ($authorUrl !== '') {
                $author['url'] = $authorUrl;
            }
        }

        // Dates.
        $datePublished = get_post_time('c', true, $post);
        $datePublished = is_string($datePublished) ? $datePublished : '';

        $dateModified = get_post_modified_time('c', true, $post);
        $dateModified = is_string($dateModified) ? $dateModified : '';
        if ($dateModified === '') {
            $dateModified = $datePublished;
        }

        // Image.
        $image = $this->resolveArticleImage($postId, (string) $post->post_content);
        if (!is_array($image)) {
            // Image is required for Article rich results.
            $this->logger->debug('Schema: article image missing; skipping BlogPosting node.');
            return null;
        }

        $headline = get_the_title($postId);
        $headline = is_string($headline) ? trim(wp_strip_all_tags($headline)) : '';
        if ($headline === '') {
            $headline = is_string($meta->title) ? trim($meta->title) : $canonical;
        }

        $desc = is_string($meta->description) ? trim($meta->description) : '';

        $node = [
            '@type' => 'BlogPosting',
            '@id'   => $canonical . '#article',
            'mainEntityOfPage' => [
                '@id' => $canonical . '#webpage',
            ],
            'isPartOf' => [
                '@id' => $websiteId,
            ],
            'headline' => $headline,
            'datePublished' => $datePublished,
            'dateModified'  => $dateModified,
            'author' => $author,
            'publisher' => [
                '@type' => 'Organization',
                '@id'   => $orgId,
                'name'  => $orgName !== '' ? $orgName : (string) home_url('/'),
                'logo'  => $logo,
            ],
            'image' => $image,
        ];

        if ($desc !== '') {
            $node['description'] = $desc;
        }

        $locale = get_locale();
        $locale = is_string($locale) ? str_replace('-', '_', $locale) : '';
        if ($locale !== '') {
            $node['inLanguage'] = $locale;
        }

        return $node;
    }

    /**
     * WooCommerce Product schema (minimal, but valid).
     *
     * @return array<string,mixed>|null
     */
    private function buildProductNode(SeoDocumentContext $ctx, SeoMeta $meta, string $canonical, string $websiteId, string $orgId): ?array
    {
        if (!$ctx->isSingular() || (string) $ctx->objectSubType !== 'product') {
            return null;
        }

        if (!function_exists('wc_get_product')) {
            return null;
        }

        $product = wc_get_product((int) $ctx->objectId);
        if (!$product || !is_object($product)) {
            return null;
        }

        $name = '';
        if (method_exists($product, 'get_name')) {
            $name = (string) $product->get_name();
        }
        $name = trim(wp_strip_all_tags($name));
        if ($name === '') {
            $name = is_string($meta->title) ? trim($meta->title) : '';
        }
        if ($name === '') {
            $name = $canonical;
        }

        $node = [
            '@type' => 'Product',
            '@id'   => $canonical . '#product',
            'name'  => $name,
            'url'   => $canonical,
            'mainEntityOfPage' => [
                '@id' => $canonical . '#webpage',
            ],
            'isPartOf' => [
                '@id' => $websiteId,
            ],
        ];

        // Description: prefer SEO description, then product short description, then product content.
        $desc = is_string($meta->description) ? trim($meta->description) : '';
        if ($desc === '' && method_exists($product, 'get_short_description')) {
            $desc = trim(wp_strip_all_tags((string) $product->get_short_description()));
        }
        if ($desc === '' && method_exists($product, 'get_description')) {
            $desc = trim(wp_strip_all_tags((string) $product->get_description()));
        }
        if ($desc !== '') {
            $node['description'] = $desc;
        }

        // Images.
        $images = $this->resolveProductImages($product);
        if (!empty($images)) {
            $node['image'] = count($images) === 1 ? $images[0] : array_values($images);
        }

        // SKU.
        if (method_exists($product, 'get_sku')) {
            $sku = trim((string) $product->get_sku());
            if ($sku !== '') {
                $node['sku'] = $sku;
            }
        }

        // Brand (best effort).
        $brand = $this->resolveProductBrand((int) $ctx->objectId);
        if ($brand !== '') {
            $node['brand'] = [
                '@type' => 'Brand',
                'name'  => $brand,
            ];
        }

        // Offers.
        $offers = $this->buildProductOffers($product, $canonical);
        if (is_array($offers)) {
            $node['offers'] = $offers;
        }

        $locale = get_locale();
        $locale = is_string($locale) ? str_replace('-', '_', $locale) : '';
        if ($locale !== '') {
            $node['inLanguage'] = $locale;
        }

        /**
         * Filter Product node.
         *
         * @since 0.23.0
         *
         * @param array<string,mixed> $node
         */
        $node = apply_filters('wpsn/schema/product_node', $node, $ctx, $meta, $product);

        return is_array($node) ? $node : null;
    }

    /**
     * @param object $product
     * @return array<int,string>
     */
    private function resolveProductImages(object $product): array
    {
        $ids = [];

        if (method_exists($product, 'get_image_id')) {
            $id = (int) $product->get_image_id();
            if ($id > 0) {
                $ids[] = $id;
            }
        }

        if (method_exists($product, 'get_gallery_image_ids')) {
            $gallery = $product->get_gallery_image_ids();
            if (is_array($gallery) && !empty($gallery)) {
                foreach ($gallery as $gid) {
                    $gid = (int) $gid;
                    if ($gid > 0) {
                        $ids[] = $gid;
                    }
                }
            }
        }

        $ids = array_values(array_unique(array_filter($ids)));
        $urls = [];
        foreach ($ids as $id) {
            $url = wp_get_attachment_image_url($id, 'full');
            $url = is_string($url) ? trim($url) : '';
            if ($url !== '') {
                $urls[] = $url;
            }
        }

        // Fallback to featured image.
        if (empty($urls) && $product instanceof \WC_Product) {
            $pid = (int) $product->get_id();
            $thumbId = (int) get_post_thumbnail_id($pid);
            if ($thumbId > 0) {
                $url = wp_get_attachment_image_url($thumbId, 'full');
                $url = is_string($url) ? trim($url) : '';
                if ($url !== '') {
                    $urls[] = $url;
                }
            }
        }

        return $urls;
    }

    /**
     * Build Offer/AggregateOffer.
     *
     * @param object $product
     * @return array<string,mixed>|null
     */
    private function buildProductOffers(object $product, string $canonical): ?array
    {
        $currency = function_exists('get_woocommerce_currency') ? (string) get_woocommerce_currency() : '';

        // Variable products: price range.
        if (method_exists($product, 'is_type') && $product->is_type('variable') && method_exists($product, 'get_variation_price')) {
            $low = $product->get_variation_price('min', true);
            $high = $product->get_variation_price('max', true);

            $low = is_numeric($low) ? (string) $low : (is_string($low) ? trim($low) : '');
            $high = is_numeric($high) ? (string) $high : (is_string($high) ? trim($high) : '');

            if ($low === '' && $high === '') {
                return null;
            }

            $count = 0;
            if (method_exists($product, 'get_children')) {
                $children = $product->get_children();
                if (is_array($children)) {
                    $count = count($children);
                }
            }

            $out = [
                '@type' => 'AggregateOffer',
                'url'   => $canonical,
            ];

            if ($currency !== '') {
                $out['priceCurrency'] = $currency;
            }
            if ($low !== '') {
                $out['lowPrice'] = $low;
            }
            if ($high !== '') {
                $out['highPrice'] = $high;
            }
            if ($count > 0) {
                $out['offerCount'] = $count;
            }

            if (method_exists($product, 'is_in_stock')) {
                $out['availability'] = $product->is_in_stock() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock';
            }

            return $out;
        }

        // Simple/grouped/external: single offer.
        if (!method_exists($product, 'get_price')) {
            return null;
        }

        $price = $product->get_price();
        $price = is_numeric($price) ? (string) $price : (is_string($price) ? trim($price) : '');
        if ($price === '') {
            return null;
        }

        $offer = [
            '@type' => 'Offer',
            'url'   => $canonical,
            'price' => $price,
        ];
        if ($currency !== '') {
            $offer['priceCurrency'] = $currency;
        }
        if (method_exists($product, 'is_in_stock')) {
            $offer['availability'] = $product->is_in_stock() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock';
        }

        return $offer;
    }

    private function resolveProductBrand(int $productId): string
    {
        $candidates = [];
        if (taxonomy_exists('product_brand')) {
            $candidates[] = 'product_brand';
        }
        if (taxonomy_exists('pa_brand')) {
            $candidates[] = 'pa_brand';
        }

        foreach ($candidates as $tax) {
            $terms = get_the_terms($productId, $tax);
            if (!is_array($terms) || empty($terms)) {
                continue;
            }
            foreach ($terms as $t) {
                if ($t instanceof \WP_Term && $t->name !== '') {
                    return (string) $t->name;
                }
            }
        }

        return '';
    }

    /**
     * Resolve organization logo from settings, theme logo or site icon.
     *
     * @return array<string, mixed>|null
     */


    /**
     * Build a Person node for a WP user (author).
     *
     * @return array<string,mixed>|null
     */
    private function buildPersonNode(int $userId, string $worksForOrgId = ''): ?array
    {
        if ($userId <= 0) {
            return null;
        }

        // Per-user toggle: if explicitly disabled, skip.
        $enabledRaw = (string) get_user_meta($userId, UserMetaKeys::AUTHOR_SCHEMA_ENABLED, true);
        if ($enabledRaw === '0') {
            return null;
        }

        $name = get_the_author_meta('display_name', $userId);
        $name = is_string($name) ? trim($name) : '';
        if ($name === '') {
            return null;
        }

        $authorUrl = get_author_posts_url($userId);
        $authorUrl = is_string($authorUrl) ? $authorUrl : '';

        $personId = '';
        if ($authorUrl !== '') {
            $personId = rtrim($authorUrl, '/') . '#person';
        } else {
            $siteUrl = home_url('/');
            $siteUrl = is_string($siteUrl) ? rtrim($siteUrl, '/') : '';
            $personId = ($siteUrl !== '' ? $siteUrl : (string) home_url('/')) . '#person-' . (string) $userId;
        }

        $node = [
            '@type' => 'Person',
            '@id'   => $personId,
            'name'  => $name,
        ];

        if ($authorUrl !== '') {
            $node['url'] = $authorUrl;
        }

        $bio = get_the_author_meta('description', $userId);
        $bio = is_string($bio) ? trim(wp_strip_all_tags($bio)) : '';
        if ($bio !== '') {
            $node['description'] = $bio;
        }

        $jobTitle = (string) get_user_meta($userId, UserMetaKeys::AUTHOR_JOB_TITLE, true);
        $jobTitle = trim($jobTitle);
        if ($jobTitle !== '') {
            $node['jobTitle'] = $jobTitle;
        }

        $img = (string) get_user_meta($userId, UserMetaKeys::AUTHOR_IMAGE_URL, true);
        $img = trim($img);
        if ($img !== '') {
            $node['image'] = esc_url_raw($img);
        }

        $sameAsRaw = (string) get_user_meta($userId, UserMetaKeys::AUTHOR_SAME_AS, true);
        $sameAs = $this->parseSameAsUrls($sameAsRaw);
        if (!empty($sameAs)) {
            $node['sameAs'] = $sameAs;
        }

        if ($worksForOrgId !== '') {
            $node['worksFor'] = [
                '@id' => $worksForOrgId,
            ];
        }

        return $node;
    }

    /**
     * Parse newline-separated URLs into a unique list.
     *
     * @return string[]
     */
    private function parseSameAsUrls(string $raw): array
    {
        $raw = trim($raw);
        if ($raw === '') {
            return [];
        }

        $lines = preg_split('/\r\n|\r|\n/', $raw);
        if (!is_array($lines)) {
            return [];
        }

        $out = [];
        foreach ($lines as $line) {
            $u = trim((string) $line);
            if ($u === '') {
                continue;
            }
            $u = esc_url_raw($u);
            if ($u === '') {
                continue;
            }
            $out[] = $u;
        }

        $out = array_values(array_unique($out));

        return $out;
    }

    private function resolveOrganizationLogo(): ?array
    {
        // 1) Explicit URL in settings.
        $url = (string) $this->settings->get('schema.org_logo_url', '');
        $url = trim($url);
        if ($url !== '') {
            $id = function_exists('attachment_url_to_postid') ? (int) attachment_url_to_postid($url) : 0;
            if ($id > 0) {
                $obj = $this->imageObjectFromAttachmentId($id);
                if (is_array($obj)) {
                    return $obj;
                }
            }
            return [
                '@type' => 'ImageObject',
                'url'   => $url,
            ];
        }

        // 2) Theme custom logo.
        $customLogoId = (int) get_theme_mod('custom_logo');
        if ($customLogoId > 0) {
            $obj = $this->imageObjectFromAttachmentId($customLogoId);
            if (is_array($obj)) {
                return $obj;
            }
        }

        // 3) Site icon.
        $siteIconId = (int) get_option('site_icon');
        if ($siteIconId > 0) {
            $obj = $this->imageObjectFromAttachmentId($siteIconId);
            if (is_array($obj)) {
                return $obj;
            }
        }

        return null;
    }

    /**
     * Resolve an article image (featured -> first content img -> site icon).
     *
     * @return array<string, mixed>|null
     */
    private function resolveArticleImage(int $postId, string $content): ?array
    {
        // Featured image.
        $thumbId = get_post_thumbnail_id($postId);
        if (is_numeric($thumbId) && (int) $thumbId > 0) {
            $obj = $this->imageObjectFromAttachmentId((int) $thumbId);
            if (is_array($obj)) {
                return $obj;
            }
        }

        // First image in content.
        if ($content !== '') {
            if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/i', $content, $m)) {
                $url = isset($m[1]) ? (string) $m[1] : '';
                $url = trim($url);
                if ($url !== '') {
                    return [
                        '@type' => 'ImageObject',
                        'url'   => $url,
                    ];
                }
            }
        }

        // Site icon fallback.
        $siteIconId = (int) get_option('site_icon');
        if ($siteIconId > 0) {
            $obj = $this->imageObjectFromAttachmentId($siteIconId);
            if (is_array($obj)) {
                return $obj;
            }
        }

        return null;
    }

    /**
     * @return array<int, string>
     */
    private function resolveOrganizationSameAs(): array
    {
        $raw = $this->settings->get('schema.org_same_as', []);
        $urls = [];

        if (is_array($raw)) {
            foreach ($raw as $u) {
                if (!is_string($u)) {
                    continue;
                }
                $u = trim($u);
                if ($u === '') {
                    continue;
                }
                $u = esc_url_raw($u);
                if ($u !== '') {
                    $urls[] = $u;
                }
            }
        }

        return array_values(array_unique($urls));
    }

    private function defaultSchemaTypeForPostType(string $postType): string
    {
        $postType = sanitize_key($postType);
        if ($postType === '') {
            return '';
        }

        $raw = (string) $this->settings->get('schema.default_types.' . $postType, '');
        $raw = sanitize_key($raw);
        if (!in_array($raw, ['none', 'article', 'webpage', 'product'], true)) {
            return '';
        }

        return $raw;
    }

    private function organizationSettingString(string $path, string $fallbackPath = ''): string
    {
        $value = (string) $this->settings->get($path, '');
        $value = trim($value);
        if ($value !== '') {
            return $value;
        }

        if ($fallbackPath !== '') {
            $fallback = (string) $this->settings->get($fallbackPath, '');
            $fallback = trim($fallback);
            if ($fallback !== '') {
                return $fallback;
            }
        }

        return '';
    }

    /**
     * @return array<string,string>
     */
    private function resolveOrganizationPolicyUrls(): array
    {
        $raw = $this->settings->get('schema.policy_urls', []);
        if (!is_array($raw) || empty($raw)) {
            return [];
        }

        $propertyMap = [
            'publishing_principles' => 'publishingPrinciples',
            'ownership_funding_info' => 'ownershipFundingInfo',
            'actionable_feedback_policy' => 'actionableFeedbackPolicy',
            'corrections_policy' => 'correctionsPolicy',
            'ethics_policy' => 'ethicsPolicy',
            'diversity_policy' => 'diversityPolicy',
            'diversity_staffing_report' => 'diversityStaffingReport',
        ];

        $out = [];
        foreach ($propertyMap as $settingKey => $property) {
            if (!isset($raw[$settingKey]) || !is_string($raw[$settingKey])) {
                continue;
            }

            $url = esc_url_raw(trim($raw[$settingKey]));
            if ($url === '') {
                continue;
            }

            $out[$property] = $url;
        }

        return $out;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function imageObjectFromAttachmentId(int $attachmentId): ?array
    {
        if ($attachmentId <= 0) {
            return null;
        }

        $src = wp_get_attachment_image_src($attachmentId, 'full');
        if (!is_array($src) || empty($src[0])) {
            return null;
        }

        $url = (string) $src[0];
        $w = isset($src[1]) ? (int) $src[1] : 0;
        $h = isset($src[2]) ? (int) $src[2] : 0;

        $obj = [
            '@type' => 'ImageObject',
            'url'   => $url,
        ];
        if ($w > 0) {
            $obj['width'] = $w;
        }
        if ($h > 0) {
            $obj['height'] = $h;
        }

        return $obj;
    }
}