<?php

namespace WPSN\Modules\SchemaValidator;

use WPSN\Core\Logger;
use WPSN\Core\Settings;
use WPSN\Domain\SeoDocumentContext;
use WPSN\SEO\SchemaCustomJsonLdResolver;
use WPSN\SEO\SchemaGraphBuilder;
use WPSN\SEO\SeoMetaRepository;
use WPSN\SEO\UserMetaKeys;

defined('ABSPATH') || exit;

/**
 * Local schema validator (rule-based).
 *
 * This does NOT call external validators. It checks our own generated JSON-LD
 * against pragmatic requirements/recommendations (Google Article rich results, etc.).
 */
final class SchemaValidationService
{
    public const SEVERITY_LOW = 1;
    public const SEVERITY_MEDIUM = 2;
    public const SEVERITY_HIGH = 3;

    // Issue codes.
    public const ISSUE_GRAPH_MISSING = 'schema_graph_missing';
    public const ISSUE_WEBSITE_MISSING = 'schema_website_missing';
    public const ISSUE_ORG_MISSING = 'schema_org_missing';
    public const ISSUE_ORG_LOGO_MISSING = 'schema_org_logo_missing';
    public const ISSUE_BREADCRUMB_MISSING = 'schema_breadcrumb_missing';

    public const ISSUE_ARTICLE_EXPECTED_MISSING = 'schema_article_missing';
    public const ISSUE_ARTICLE_SKIPPED_LOGO = 'schema_article_skipped_logo';
    public const ISSUE_ARTICLE_SKIPPED_IMAGE = 'schema_article_skipped_image';
    public const ISSUE_ARTICLE_INVALID = 'schema_article_invalid';

    public const ISSUE_PRODUCT_EXPECTED_MISSING = 'schema_product_missing';
    public const ISSUE_PRODUCT_INVALID = 'schema_product_invalid';

    public const ISSUE_CUSTOM_JSONLD_INVALID = 'schema_custom_jsonld_invalid';

    public const ISSUE_PERSON_BIO_MISSING = 'schema_person_bio_missing';
    public const ISSUE_PERSON_SAMEAS_MISSING = 'schema_person_sameas_missing';
    public const ISSUE_PERSON_IMAGE_MISSING = 'schema_person_image_missing';

    // Quality guideline warnings (best-effort).
    public const ISSUE_GUIDELINE_ORG_LOGO_SMALL = 'schema_guideline_org_logo_small';
    public const ISSUE_GUIDELINE_ARTICLE_IMAGE_SMALL = 'schema_guideline_article_image_small';
    public const ISSUE_GUIDELINE_ARTICLE_HEADLINE_LONG = 'schema_guideline_article_headline_long';
    public const ISSUE_GUIDELINE_ARTICLE_DATE_MODIFIED_MISSING = 'schema_guideline_article_date_modified_missing';


    private SeoMetaRepository $metaRepo;
    private SchemaGraphBuilder $schemaBuilder;
    private Settings $settings;
    private Logger $logger;
    private ?SchemaCustomJsonLdResolver $customJsonLd;

    public function __construct(SeoMetaRepository $metaRepo, SchemaGraphBuilder $schemaBuilder, Settings $settings, Logger $logger, ?SchemaCustomJsonLdResolver $customJsonLd = null)
    {
        $this->metaRepo = $metaRepo;
        $this->schemaBuilder = $schemaBuilder;
        $this->settings = $settings;
        $this->logger = $logger;
        $this->customJsonLd = $customJsonLd;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function validatePost(int $postId, string $postType): array
    {
        $url = get_permalink($postId);
        $url = is_string($url) ? $url : '';

        $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_SINGULAR, $postId, $postType, $url, 1);

        return $this->validateContext($ctx);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function validateTerm(int $termId, string $taxonomy): array
    {
        $term = get_term($termId, $taxonomy);
        $url = ($term instanceof \WP_Term) ? get_term_link($term) : '';
        $url = is_string($url) ? $url : '';

        $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_TERM, $termId, $taxonomy, $url, 1);

        return $this->validateContext($ctx);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function validateContext(SeoDocumentContext $ctx): array
    {
        $issues = [];

        // Skip invalid URLs.
        if (!is_string($ctx->url) || trim($ctx->url) === '') {
            return $issues;
        }

        if ($this->customJsonLd !== null) {
            $custom = $this->customJsonLd->resolve($ctx);
            foreach ((array) ($custom['errors'] ?? []) as $error) {
                if (!is_array($error)) {
                    continue;
                }

                $label = isset($error['label']) ? (string) $error['label'] : __('Custom JSON-LD', 'wp-seo-next');
                $message = isset($error['message']) ? (string) $error['message'] : __('Invalid JSON.', 'wp-seo-next');

                $issues[] = $this->issue(
                    $ctx,
                    self::ISSUE_CUSTOM_JSONLD_INVALID,
                    self::SEVERITY_MEDIUM,
                    sprintf(__('Invalid custom JSON-LD was skipped: %s.', 'wp-seo-next'), $label),
                    __('Fix the JSON syntax or make sure token-based string values use the |json transform (for example %title|json%).', 'wp-seo-next'),
                    [
                        'source' => isset($error['source']) ? (string) $error['source'] : '',
                        'message' => $message,
                    ]
                );
            }
        }

        $meta = $this->metaRepo->forContext($ctx);
        $graph = $this->schemaBuilder->build($ctx, $meta);

        // If schema output is disabled for the context (none) or 404, builder returns null.
        if (!is_array($graph)) {
            // Only warn on singular/term where schema is expected.
            if ($ctx->isSingular() || $ctx->isTerm()) {
                $issues[] = $this->issue(
                    $ctx,
                    self::ISSUE_GRAPH_MISSING,
                    self::SEVERITY_MEDIUM,
                    __('No JSON-LD schema output for this page.', 'wp-seo-next'),
                    __('Enable Schema module and make sure schema type is not set to “None”.', 'wp-seo-next'),
                    [
                        'schema_type' => $this->expectedSchemaType($ctx),
                    ]
                );
            }
            return $issues;
        }

        $nodes = isset($graph['@graph']) && is_array($graph['@graph']) ? $graph['@graph'] : [];

        if (empty($nodes)) {
            $issues[] = $this->issue(
                $ctx,
                self::ISSUE_GRAPH_MISSING,
                self::SEVERITY_MEDIUM,
                __('Schema graph is empty.', 'wp-seo-next'),
                __('Enable schema types for this content.', 'wp-seo-next')
            );
            return $issues;
        }

        $website = $this->findFirstByType($nodes, 'WebSite');
        if (!$website) {
            $issues[] = $this->issue(
                $ctx,
                self::ISSUE_WEBSITE_MISSING,
                self::SEVERITY_HIGH,
                __('Missing WebSite node in schema graph.', 'wp-seo-next'),
                __('Check schema settings and filters that may remove WebSite.', 'wp-seo-next')
            );
        }

        $org = $this->findFirstByType($nodes, 'Organization');
        if (!$org) {
            $issues[] = $this->issue(
                $ctx,
                self::ISSUE_ORG_MISSING,
                self::SEVERITY_MEDIUM,
                __('Missing Organization node in schema graph.', 'wp-seo-next'),
                __('Set an Organization name/logo in Schema settings.', 'wp-seo-next')
            );
        } else {
            if (empty($org['logo']) || !is_array($org['logo']) || empty($org['logo']['url'])) {
                $issues[] = $this->issue(
                    $ctx,
                    self::ISSUE_ORG_LOGO_MISSING,
                    self::SEVERITY_HIGH,
                    __('Organization logo is missing in schema output.', 'wp-seo-next'),
                    __('Set a logo in Settings → Schema, or set a Site Icon / theme logo. This is required for Article rich results.', 'wp-seo-next')
                );
            } else {
                // Best-effort guideline warnings (logo size, etc.).
                $issues = array_merge($issues, $this->validateOrgGuidelines($ctx, $org));
            }
        }

        $breadcrumb = $this->findFirstByType($nodes, 'BreadcrumbList');
        if (!$breadcrumb) {
            // Breadcrumbs are recommended for many page types.
            $issues[] = $this->issue(
                $ctx,
                self::ISSUE_BREADCRUMB_MISSING,
                self::SEVERITY_LOW,
                __('BreadcrumbList is missing in schema output.', 'wp-seo-next'),
                __('Breadcrumb schema is recommended for better understanding of site hierarchy.', 'wp-seo-next')
            );
        }

        // Article validation.
        $expected = $this->expectedSchemaType($ctx);
        if ($expected === 'article') {
            $article = $this->findFirstByType($nodes, 'BlogPosting');
            if (!$article) {
                // Try to infer why it was skipped.
                $hasLogo = $org && !empty($org['logo']) && is_array($org['logo']) && !empty($org['logo']['url']);
                $hasImage = $this->postHasArticleImage($ctx);

                if (!$hasLogo) {
                    $issues[] = $this->issue(
                        $ctx,
                        self::ISSUE_ARTICLE_SKIPPED_LOGO,
                        self::SEVERITY_HIGH,
                        __('Article schema was skipped because publisher logo is missing.', 'wp-seo-next'),
                        __('Set a Schema logo (or Site Icon / theme logo) and rescan.', 'wp-seo-next')
                    );
                } elseif (!$hasImage) {
                    $issues[] = $this->issue(
                        $ctx,
                        self::ISSUE_ARTICLE_SKIPPED_IMAGE,
                        self::SEVERITY_HIGH,
                        __('Article schema was skipped because no image was found.', 'wp-seo-next'),
                        __('Add a featured image (recommended) or set a Site Icon as a fallback.', 'wp-seo-next')
                    );
                } else {
                    $issues[] = $this->issue(
                        $ctx,
                        self::ISSUE_ARTICLE_EXPECTED_MISSING,
                        self::SEVERITY_MEDIUM,
                        __('Article schema is expected but BlogPosting node is missing.', 'wp-seo-next'),
                        __('Check schema filters/hooks and make sure schema type is set to Article.', 'wp-seo-next')
                    );
                }
            } else {
                $articleIssues = $this->validateArticleNode($ctx, $article, $nodes, $expected);
                $issues = array_merge($issues, $articleIssues);

                // Best-effort guideline warnings (headline length, image size, dateModified, etc.).
                $issues = array_merge($issues, $this->validateArticleGuidelines($ctx, $article));
            }
        }

        // Product validation (WooCommerce product pages).
        if ($expected === 'product') {
            $product = $this->findFirstByType($nodes, 'Product');
            if (!$product) {
                $issues[] = $this->issue(
                    $ctx,
                    self::ISSUE_PRODUCT_EXPECTED_MISSING,
                    self::SEVERITY_MEDIUM,
                    __('Product schema is expected but Product node is missing.', 'wp-seo-next'),
                    __('Enable WooCommerce integration and make sure schema type is set to Product (or Default for products).', 'wp-seo-next')
                );
            } else {
                $productIssues = $this->validateProductNode($ctx, $product);
                $issues = array_merge($issues, $productIssues);
            }
        }

        return $issues;
    }

    private function expectedSchemaType(SeoDocumentContext $ctx): string
    {
        if (!$ctx->isSingular()) {
            return 'webpage';
        }

        $postId = (int) $ctx->objectId;
        $raw = (string) get_post_meta($postId, \WPSN\SEO\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') {
            // Only expect Product schema when WooCommerce integration is enabled.
            $wcMod = (bool) $this->settings->moduleEnabled('woocommerce', true);
            $wcSchema = (bool) $this->settings->get('woocommerce.schema.enabled', true);
            if (!$wcMod || !$wcSchema || !class_exists('WooCommerce') || !function_exists('wc_get_product')) {
                return 'webpage';
            }
            return 'product';
        }

        // Default.
        $pt = $ctx->objectSubType ?: (string) get_post_type($postId);
        if ($pt === 'post') {
            return 'article';
        }
        if ($pt === 'product') {
            $wcMod = (bool) $this->settings->moduleEnabled('woocommerce', true);
            $wcSchema = (bool) $this->settings->get('woocommerce.schema.enabled', true);
            if (!$wcMod || !$wcSchema || !class_exists('WooCommerce') || !function_exists('wc_get_product')) {
                return 'webpage';
            }
            return 'product';
        }
        return 'webpage';
    }

    /**
     * @param array<string,mixed> $product
     * @return array<int,array<string,mixed>>
     */
    private function validateProductNode(SeoDocumentContext $ctx, array $product): array
    {
        $issues = [];

        $name = isset($product['name']) ? trim((string) $product['name']) : '';
        if ($name === '') {
            $issues[] = $this->issue(
                $ctx,
                self::ISSUE_PRODUCT_INVALID,
                self::SEVERITY_MEDIUM,
                __('Product schema is missing name.', 'wp-seo-next'),
                __('Make sure the product has a title, and schema filters do not remove it.', 'wp-seo-next')
            );
        }

        // Offers are recommended for Product rich results.
        if (!isset($product['offers'])) {
            $issues[] = $this->issue(
                $ctx,
                self::ISSUE_PRODUCT_INVALID,
                self::SEVERITY_HIGH,
                __('Product schema is missing offers.', 'wp-seo-next'),
                __('Set a price for the product (or variations) and rescan.', 'wp-seo-next')
            );
            return $issues;
        }

        $offers = $product['offers'];
        if (is_array($offers) && isset($offers['@type'])) {
            $type = (string) $offers['@type'];
            if ($type === 'Offer') {
                $price = isset($offers['price']) ? trim((string) $offers['price']) : '';
                $cur = isset($offers['priceCurrency']) ? trim((string) $offers['priceCurrency']) : '';
                if ($price === '' || $cur === '') {
                    $issues[] = $this->issue(
                        $ctx,
                        self::ISSUE_PRODUCT_INVALID,
                        self::SEVERITY_HIGH,
                        __('Product Offer is missing price or currency.', 'wp-seo-next'),
                        __('Ensure WooCommerce currency is set and the product has a price.', 'wp-seo-next')
                    );
                }
            } elseif ($type === 'AggregateOffer') {
                $low = isset($offers['lowPrice']) ? trim((string) $offers['lowPrice']) : '';
                $high = isset($offers['highPrice']) ? trim((string) $offers['highPrice']) : '';
                $cur = isset($offers['priceCurrency']) ? trim((string) $offers['priceCurrency']) : '';
                if (($low === '' && $high === '') || $cur === '') {
                    $issues[] = $this->issue(
                        $ctx,
                        self::ISSUE_PRODUCT_INVALID,
                        self::SEVERITY_HIGH,
                        __('Product AggregateOffer is missing prices or currency.', 'wp-seo-next'),
                        __('Ensure WooCommerce currency is set and variations have prices.', 'wp-seo-next')
                    );
                }
            }
        }

        return $issues;
    }

    /**
     * @param array<int,mixed> $nodes
     * @return array<string,mixed>|null
     */
    private function findFirstByType(array $nodes, string $type): ?array
    {
        foreach ($nodes as $n) {
            if (!is_array($n)) {
                continue;
            }
            if ($this->nodeHasType($n, $type)) {
                return $n;
            }
        }
        return null;
    }

    /**
     * @param array<string,mixed> $node
     */
    private function nodeHasType(array $node, string $type): bool
    {
        if (!isset($node['@type'])) {
            return false;
        }
        $t = $node['@type'];
        if (is_string($t)) {
            return $t === $type;
        }
        if (is_array($t)) {
            return in_array($type, $t, true);
        }
        return false;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function validateArticleNode(SeoDocumentContext $ctx, array $article, array $nodes, string $expectedSchemaType): array
    {
        $issues = [];

        $missing = [];
        foreach (['headline', 'datePublished', 'author', 'publisher', 'image', 'mainEntityOfPage'] as $k) {
            if (!array_key_exists($k, $article) || $article[$k] === null || $article[$k] === '') {
                $missing[] = $k;
            }
        }

        // author.name
        if (isset($article['author']) && is_array($article['author'])) {
            $a = $article['author'];
            if (empty($a['name'])) {
                $missing[] = 'author.name';
            }
        }

        // publisher.logo.url
        if (isset($article['publisher']) && is_array($article['publisher'])) {
            $p = $article['publisher'];
            if (empty($p['name'])) {
                $missing[] = 'publisher.name';
            }
            if (empty($p['logo']) || !is_array($p['logo']) || empty($p['logo']['url'])) {
                $missing[] = 'publisher.logo.url';
            }
        }

        // image.url
        if (isset($article['image']) && is_array($article['image'])) {
            $img = $article['image'];
            if (empty($img['url'])) {
                $missing[] = 'image.url';
            }
        }

        if (!empty($missing)) {
            $issues[] = $this->issue(
                $ctx,
                self::ISSUE_ARTICLE_INVALID,
                self::SEVERITY_HIGH,
                sprintf(
                    /* translators: %s is a comma-separated list of missing fields */
                    __('Article schema is missing required fields: %s', 'wp-seo-next'),
                    implode(', ', $missing)
                ),
                __('Fill the missing fields (logo, image, author) and rescan.', 'wp-seo-next'),
                [
                    'missing' => $missing,
                ]
            );
        }

        // Person (Author SEO) checks.
        foreach ($this->validateAuthorPerson($ctx, $nodes, $expectedSchemaType) as $pi) {
            $issues[] = $pi;
        }

        return $issues;
    }

    /**
     * Validate author Person node (Author SEO module).
     *
     * @param array<int,mixed> $nodes
     * @return array<int,array<string,mixed>>
     */
    private function validateAuthorPerson(SeoDocumentContext $ctx, array $nodes, string $expectedSchemaType): array
    {
        $out = [];

        if (!(bool) $this->settings->moduleEnabled('author_seo', true)) {
            return $out;
        }

        $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() && $expectedSchemaType === 'article') {
            $post = get_post((int) $ctx->objectId);
            if ($post instanceof \WP_Post) {
                $userId = (int) $post->post_author;
            }
        }

        if ($userId <= 0) {
            return $out;
        }

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

        // Expected Person @id (keep in sync with SchemaGraphBuilder).
        $authorUrl = get_author_posts_url($userId);
        $authorUrl = is_string($authorUrl) ? $authorUrl : '';

        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;
        }

        $person = $this->findById($nodes, $personId);
        if (!$person || (string) ($person['@type'] ?? '') !== 'Person') {
            // No Person node found; do not report missing fields to avoid false positives.
            return $out;
        }

        // Bio/description.
        $bio = '';
        if (isset($person['description']) && is_string($person['description'])) {
            $bio = trim((string) $person['description']);
        }
        if ($bio === '') {
            $out[] = $this->issueForUser(
                $userId,
                self::ISSUE_PERSON_BIO_MISSING,
                self::SEVERITY_MEDIUM,
                __('Person schema is missing description (author bio).', 'wp-seo-next'),
                __('Add Biographical Info to the user profile and rescan.', 'wp-seo-next'),
                [
                    'field' => 'description',
                    'person_id' => $personId,
                ]
            );
        }

        // sameAs.
        $sameAsOk = false;
        if (isset($person['sameAs']) && is_array($person['sameAs'])) {
            $sameAsOk = !empty(array_filter($person['sameAs'], static function ($u): bool {
                return is_string($u) && trim((string) $u) !== '';
            }));
        }
        if (!$sameAsOk) {
            $out[] = $this->issueForUser(
                $userId,
                self::ISSUE_PERSON_SAMEAS_MISSING,
                self::SEVERITY_LOW,
                __('Person schema is missing sameAs (social/profile URLs).', 'wp-seo-next'),
                __('Add SameAs URLs in the user profile (one URL per line) and rescan.', 'wp-seo-next'),
                [
                    'field' => 'sameAs',
                    'person_id' => $personId,
                ]
            );
        }

        // image.
        $img = '';
        if (isset($person['image'])) {
            if (is_string($person['image'])) {
                $img = trim((string) $person['image']);
            } elseif (is_array($person['image']) && isset($person['image']['url']) && is_string($person['image']['url'])) {
                $img = trim((string) $person['image']['url']);
            }
        }
        if ($img === '') {
            $out[] = $this->issueForUser(
                $userId,
                self::ISSUE_PERSON_IMAGE_MISSING,
                self::SEVERITY_MEDIUM,
                __('Person schema is missing image.', 'wp-seo-next'),
                __('Set an author image in the user profile and rescan.', 'wp-seo-next'),
                [
                    'field' => 'image',
                    'person_id' => $personId,
                ]
            );
        }

        return $out;
    }

    /**
     * @param array<string,mixed> $details
     * @return array<string,mixed>
     */
    private function issueForUser(int $userId, string $code, int $severity, string $message, string $suggestion, array $details = []): array
    {
        $userId = (int) $userId;

        $title = '';
        $u = get_user_by('id', $userId);
        if ($u instanceof \WP_User) {
            $title = (string) $u->display_name;
        }

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

        return [
            'object_type' => 'user',
            'object_id' => $userId,
            'object_subtype' => 'user',
            'object_title' => $title,
            'object_url' => $url,
            'issue_code' => $code,
            'severity' => $severity,
            'message' => $message,
            'suggestion' => $suggestion,
            'details' => !empty($details) ? wp_json_encode($details) : null,
        ];
    }

    private function postHasArticleImage(SeoDocumentContext $ctx): bool
    {
        if (!$ctx->isSingular()) {
            return false;
        }

        $postId = (int) $ctx->objectId;
        if ($postId <= 0) {
            return false;
        }

        $thumbId = (int) get_post_thumbnail_id($postId);
        if ($thumbId > 0) {
            return true;
        }

        $post = get_post($postId);
        if ($post instanceof \WP_Post) {
            if (is_string($post->post_content) && preg_match('/<img[^>]+src=["\']([^"\']+)["\']/i', $post->post_content)) {
                return true;
            }
        }

        $siteIconId = (int) get_option('site_icon');
        return $siteIconId > 0;
    }


    /**
     * Find node by @id in a graph list.
     *
     * @param array<int,mixed> $nodes
     * @return array<string,mixed>|null
     */
    private function findById(array $nodes, string $id): ?array
    {
        if ($id === '') {
            return null;
        }
        foreach ($nodes as $n) {
            if (!is_array($n)) {
                continue;
            }
            if (isset($n['@id']) && is_string($n['@id']) && $n['@id'] === $id) {
                /** @var array<string,mixed> $n */
                return $n;
            }
        }
        return null;
    }

    /**
     * Best-effort: get image dimensions for a URL if it's a local attachment.
     *
     * @return array{attachment_id:int,width:int,height:int}|null
     */
    private function getImageDimensions(string $url): ?array
    {
        $url = trim($url);
        if ($url === '') {
            return null;
        }

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

        $attId = (int) attachment_url_to_postid($url);
        if ($attId <= 0) {
            return null;
        }

        $meta = wp_get_attachment_metadata($attId);
        if (!is_array($meta)) {
            return null;
        }

        $w = isset($meta['width']) ? (int) $meta['width'] : 0;
        $h = isset($meta['height']) ? (int) $meta['height'] : 0;
        if ($w <= 0 || $h <= 0) {
            return null;
        }

        return [
            'attachment_id' => $attId,
            'width' => $w,
            'height' => $h,
        ];
    }

    /**
     * Quality guideline warnings for Organization node (best-effort).
     *
     * @param array<string,mixed> $org
     * @return array<int,array<string,mixed>>
     */
    private function validateOrgGuidelines(SeoDocumentContext $ctx, array $org): array
    {
        $issues = [];

        $logoUrl = '';
        if (isset($org['logo'])) {
            if (is_array($org['logo']) && isset($org['logo']['url']) && is_string($org['logo']['url'])) {
                $logoUrl = (string) $org['logo']['url'];
            } elseif (is_string($org['logo'])) {
                $logoUrl = (string) $org['logo'];
            }
        }

        if ($logoUrl !== '') {
            $dims = $this->getImageDimensions($logoUrl);
            if ($dims) {
                $w = (int) ($dims['width'] ?? 0);
                $h = (int) ($dims['height'] ?? 0);
                if ($w > 0 && $h > 0 && ($w < 112 || $h < 112)) {
                    $issues[] = $this->issue(
                        $ctx,
                        self::ISSUE_GUIDELINE_ORG_LOGO_SMALL,
                        self::SEVERITY_LOW,
                        __('Organization logo seems smaller than recommended.', 'wp-seo-next'),
                        __('Use a square logo with at least 112×112px to improve eligibility for Article rich results.', 'wp-seo-next'),
                        [
                            'logo_url' => $logoUrl,
                            'width' => $w,
                            'height' => $h,
                        ]
                    );
                }
            }
        }

        return $issues;
    }

    /**
     * Quality guideline warnings for Article node (best-effort).
     *
     * @param array<string,mixed> $article
     * @return array<int,array<string,mixed>>
     */
    private function validateArticleGuidelines(SeoDocumentContext $ctx, array $article): array
    {
        $issues = [];

        // Headline length (SEO best-practice).
        $headline = isset($article['headline']) && is_string($article['headline']) ? trim($article['headline']) : '';
        if ($headline !== '' && function_exists('mb_strlen')) {
            $len = (int) mb_strlen($headline);
            if ($len > 110) {
                $issues[] = $this->issue(
                    $ctx,
                    self::ISSUE_GUIDELINE_ARTICLE_HEADLINE_LONG,
                    self::SEVERITY_LOW,
                    __('Article headline looks long.', 'wp-seo-next'),
                    __('Consider shortening the headline (often ~60–110 characters) for better display in search results.', 'wp-seo-next'),
                    [
                        'length' => $len,
                        'headline' => $headline,
                    ]
                );
            }
        }

        // dateModified is recommended.
        $dateModified = isset($article['dateModified']) && is_string($article['dateModified']) ? trim($article['dateModified']) : '';
        if ($dateModified === '') {
            $issues[] = $this->issue(
                $ctx,
                self::ISSUE_GUIDELINE_ARTICLE_DATE_MODIFIED_MISSING,
                self::SEVERITY_LOW,
                __('Article dateModified is missing.', 'wp-seo-next'),
                __('Set a last updated date when content changes. It can improve freshness signals and rich results consistency.', 'wp-seo-next')
            );
        }

        // Image size: best-effort for local attachments.
        $imgUrl = '';
        if (isset($article['image'])) {
            if (is_array($article['image']) && isset($article['image']['url']) && is_string($article['image']['url'])) {
                $imgUrl = (string) $article['image']['url'];
            } elseif (is_string($article['image'])) {
                $imgUrl = (string) $article['image'];
            } elseif (is_array($article['image']) && isset($article['image'][0]) && is_string($article['image'][0])) {
                $imgUrl = (string) $article['image'][0];
            }
        }

        if ($imgUrl !== '') {
            $dims = $this->getImageDimensions($imgUrl);
            if ($dims) {
                $w = (int) ($dims['width'] ?? 0);
                $h = (int) ($dims['height'] ?? 0);
                // Google docs commonly mention >= 696px wide for Article rich results.
                if ($w > 0 && $h > 0 && $w < 696) {
                    $issues[] = $this->issue(
                        $ctx,
                        self::ISSUE_GUIDELINE_ARTICLE_IMAGE_SMALL,
                        self::SEVERITY_LOW,
                        __('Article image seems smaller than recommended.', 'wp-seo-next'),
                        __('Use a larger featured image (often recommended: at least 696px wide; ideally 1200px+) for rich results and previews.', 'wp-seo-next'),
                        [
                            'image_url' => $imgUrl,
                            'width' => $w,
                            'height' => $h,
                        ]
                    );
                }
            }
        }

        return $issues;
    }


    /**
     * @param array<string,mixed> $details
     * @return array<string,mixed>
     */
    private function issue(SeoDocumentContext $ctx, string $code, int $severity, string $message, string $suggestion, array $details = []): array
    {
        $objectType = $ctx->isSingular() ? 'post' : ($ctx->isTerm() ? 'term' : $ctx->type);
        $objectId = $ctx->objectId ?: 0;
        $sub = $ctx->objectSubType ?: '';

        $title = '';
        if ($ctx->isSingular()) {
            $t = get_the_title((int) $objectId);
            $title = is_string($t) ? $t : '';
        } elseif ($ctx->isTerm()) {
            $term = get_term((int) $objectId, (string) $sub);
            if ($term instanceof \WP_Term) {
                $title = (string) $term->name;
            }
        }

        return [
            'object_type' => $objectType,
            'object_id' => (int) $objectId,
            'object_subtype' => (string) $sub,
            'object_title' => (string) $title,
            'object_url' => (string) $ctx->url,
            'issue_code' => $code,
            'severity' => $severity,
            'message' => $message,
            'suggestion' => $suggestion,
            'details' => !empty($details) ? wp_json_encode($details) : null,
        ];
    }
}
