<?php

namespace WPSN\Modules\Audit;

use WPSN\Core\Logger;
use WPSN\Core\Settings;
use WPSN\Domain\SeoDocumentContext;
use WPSN\SEO\MetaKeys;
use WPSN\SEO\SeoMetaRepository;
use WPSN\Modules\LocalSEO\LocalSEOModule;
use WPSN\Modules\LocalSEO\LocalMetaKeys;
use WPSN\Modules\SiteCrawl\SiteCrawlUrlKey;

defined('ABSPATH') || exit;

/**
 * Stage 8: Rule-based audit runner.
 */
final class AuditRunner
{
    public const SEVERITY_LOW    = 1;
    public const SEVERITY_MEDIUM = 2;
    public const SEVERITY_HIGH   = 3;

    public const ISSUE_MISSING_TITLE       = 'missing_title';
    public const ISSUE_MISSING_DESCRIPTION = 'missing_description';
    public const ISSUE_DUPLICATE_TITLE     = 'duplicate_title';
    public const ISSUE_DUPLICATE_DESCRIPTION = 'duplicate_description';
    public const ISSUE_TITLE_TOO_SHORT     = 'title_too_short';
    public const ISSUE_TITLE_TOO_LONG      = 'title_too_long';
    public const ISSUE_DESCRIPTION_TOO_SHORT = 'description_too_short';
    public const ISSUE_DESCRIPTION_TOO_LONG = 'description_too_long';
    public const ISSUE_TITLE_TEMPLATE_BROKEN = 'title_template_broken';
    public const ISSUE_DESCRIPTION_TEMPLATE_BROKEN = 'description_template_broken';
    public const ISSUE_NOINDEX             = 'noindex';
    public const ISSUE_CANONICAL_INVALID   = 'canonical_invalid';
    public const ISSUE_CANONICAL_EXTERNAL  = 'canonical_external';
    public const ISSUE_CANONICAL_MISMATCH  = 'canonical_mismatch';
    public const ISSUE_SOCIAL_MISSING_IMAGE = 'social_missing_image';

    // Stage 27: News/Video sitemap validation
    public const ISSUE_NEWS_SITEMAP_EMPTY = 'news_sitemap_empty';
    public const ISSUE_NEWS_SITEMAP_MISSING_POST_TITLE = 'news_sitemap_missing_post_title';

    public const ISSUE_VIDEO_SITEMAP_EMPTY = 'video_sitemap_empty';
    public const ISSUE_VIDEO_SITEMAP_INVALID_URL = 'video_sitemap_invalid_url';
    public const ISSUE_VIDEO_SITEMAP_MISSING_THUMBNAIL = 'video_sitemap_missing_thumbnail';
    public const ISSUE_VIDEO_SITEMAP_MISSING_TITLE = 'video_sitemap_missing_title';
    public const ISSUE_VIDEO_SITEMAP_MISSING_DESCRIPTION = 'video_sitemap_missing_description';
    public const ISSUE_VIDEO_SITEMAP_INVALID_DURATION = 'video_sitemap_invalid_duration';

    // Local SEO validation
    public const ISSUE_LOCAL_HOME_MISSING_BUSINESS_INFO = 'local_home_missing_business_info';
    public const ISSUE_LOCAL_LOCATION_MISSING_FIELDS = 'local_location_missing_fields';
    public const ISSUE_LOCAL_LOCATION_MISSING_GEO = 'local_location_missing_geo';
    public const ISSUE_LOCAL_NAP_MISMATCH_SETTINGS = 'local_nap_mismatch_settings';

    private SeoMetaRepository $metaRepo;
    private AuditIssueRepository $issues;
    private AuditScanRepository $scans;
    private AuditScanIssueRepository $scanIssues;
    private Logger $logger;
    private Settings $settings;

    /**
     * Per-run in-memory cache to avoid repeated meta resolving (scan jobs call meta multiple times).
     *
     * @var array<string,mixed>
     */
    private array $metaCache = [];

    // Cached sitemap audit settings (primed per scan).
    private bool $newsSitemapEnabled = false;
    /** @var array<int,string> */
    private array $newsPostTypes = [];
    private int $newsSinceTs = 0;

    private bool $videoSitemapEnabled = false;
    private string $videoMode = 'meta';
    private bool $videoIncludeFeaturedThumbnail = true;

    // Counters
    private int $newsEligible = 0;
    private int $videoCandidates = 0;
    private int $videoEligible = 0;

    public function __construct(
        SeoMetaRepository $metaRepo,
        AuditIssueRepository $issues,
        AuditScanRepository $scans,
        AuditScanIssueRepository $scanIssues,
        Logger $logger,
        Settings $settings
    )
    {
        $this->metaRepo = $metaRepo;
        $this->issues   = $issues;
        $this->scans    = $scans;
        $this->scanIssues = $scanIssues;
        $this->logger   = $logger;
        $this->settings = $settings;
    }


    /**
     * @return object
     */
    private function metaForContextCached(SeoDocumentContext $ctx)
    {
        $k = $ctx->type . ':' . (string) ((int) ($ctx->objectId ?? 0)) . ':' . (string) ($ctx->objectSubType ?? '') . ':' . (string) ((int) $ctx->paged);
        if (isset($this->metaCache[$k])) {
            return $this->metaCache[$k];
        }

        // Compute meta once and cache for this scan run.
        // NOTE: do NOT call metaForContextCached() recursively.
        $meta = $this->metaRepo->forContext($ctx);
        $this->metaCache[$k] = $meta;

        return $meta;
    }

    /**
     * @return array<string, mixed>
     */
    public function runFullScan(bool $includeTerms = true, bool $checkSocialImage = true): array
    {
        if (function_exists('wp_raise_memory_limit')) {
            wp_raise_memory_limit('admin');
        }
        if (function_exists('set_time_limit')) {
            // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
            @set_time_limit(0);
        }

        $startedAt = time();

        // Create history scan record.
        $scanId = $this->scans->createScan($includeTerms, $checkSocialImage);

        try {

            // Keep latest-issues table in sync (fast UI). History is stored separately.
            $this->issues->truncate();

            // Prime sitemap audit settings once per scan.
            $this->newsSitemapEnabled = (bool) $this->settings->get('sitemap.news.enabled', false);
            $this->newsPostTypes = [];
            if ($this->newsSitemapEnabled) {
                $raw = $this->settings->get('sitemap.news.post_types', []);
                if (is_array($raw)) {
                    foreach ($raw as $pt => $on) {
                        if (!empty($on)) {
                            $this->newsPostTypes[] = (string) $pt;
                        }
                    }
                }
                if ($this->newsPostTypes === []) {
                    $this->newsPostTypes = ['post'];
                }
                $this->newsSinceTs = time() - (48 * HOUR_IN_SECONDS);
            } else {
                $this->newsSinceTs = 0;
            }

            $this->videoSitemapEnabled = (bool) $this->settings->get('sitemap.video.enabled', false);
            if ($this->videoSitemapEnabled) {
                $this->videoMode = (string) $this->settings->get('sitemap.video.mode', 'meta');
                $this->videoIncludeFeaturedThumbnail = (bool) $this->settings->get('sitemap.video.include_featured_thumbnail', true);
            } else {
                $this->videoMode = 'meta';
                $this->videoIncludeFeaturedThumbnail = true;
            }

            // Reset counters
            $this->newsEligible = 0;
            $this->videoCandidates = 0;
            $this->videoEligible = 0;

            $counts = [];
            $issuesTotal = 0;

            $scannedPosts = 0;
            $scannedTerms = 0;

        // Used for duplicate title detection among published posts.
            /** @var array<string, int|array<int>> $titleMap */
            $titleMap = [];

            $publicPostTypes = get_post_types(['public' => true], 'names');
            if (is_array($publicPostTypes)) {
                foreach ($publicPostTypes as $pt) {
                    $pt = (string) $pt;
                    if ($pt === 'attachment') {
                        continue;
                    }

                    $page = 1;
                    $perPage = 200;

                    do {
                        $q = new \WP_Query([
                            'post_type'              => $pt,
                            'post_status'            => 'publish',
                            'fields'                 => 'ids',
                            'posts_per_page'         => $perPage,
                            'paged'                  => $page,
                            'orderby'                => 'ID',
                            'order'                  => 'ASC',
                            'no_found_rows'          => true,
                            'update_post_meta_cache' => false,
                            'update_post_term_cache' => false,
                            'suppress_filters'       => true,
                        ]);

                        $ids = is_array($q->posts) ? $q->posts : [];
                        if (empty($ids)) {
                            break;
                        }

                        foreach ($ids as $id) {
                            $id = (int) $id;
                            if ($id <= 0) {
                                continue;
                            }

                            $scannedPosts++;

                            $postIssues = $this->auditPost($id, $pt, $checkSocialImage);
                            foreach ($postIssues as $issue) {
                                $this->issues->insert($issue);
                                $this->scanIssues->insert($scanId, $issue);
                                $issuesTotal++;

                                $code = (string) ($issue['issue_code'] ?? '');
                                if ($code !== '') {
                                    $counts[$code] = (int) ($counts[$code] ?? 0) + 1;
                                }
                            }

                            // Collect for duplicates.
                            $seoTitle = $this->getSeoTitleForPost($id, $pt);
                            $key = $this->normalizeTitleKey($seoTitle);
                            if ($key !== '') {
                                if (!isset($titleMap[$key])) {
                                    $titleMap[$key] = $id;
                                } elseif (is_int($titleMap[$key])) {
                                    $titleMap[$key] = [(int) $titleMap[$key], $id];
                                } elseif (is_array($titleMap[$key])) {
                                    $titleMap[$key][] = $id;
                                }
                            }
                        }

                        $page++;
                    } while (true);
                }
            }

            // Duplicate titles.
            foreach ($titleMap as $key => $ids) {
                if (!is_array($ids) || count($ids) < 2) {
                    continue;
                }

                $count = count($ids);
                foreach ($ids as $id) {
                    $id = (int) $id;
                    if ($id <= 0) {
                        continue;
                    }

                    $url = get_permalink($id);
                    if (!is_string($url) || $url === '') {
                        $url = '';
                    }

                    $issueRow = [
                        'object_type'    => 'post',
                        'object_id'      => $id,
                        'object_subtype' => (string) get_post_type($id),
                        'object_title'   => (string) get_the_title($id),
                        'object_url'     => (string) $url,
                        'issue_code'     => self::ISSUE_DUPLICATE_TITLE,
                        'severity'       => self::SEVERITY_MEDIUM,
                        'message'        => sprintf(
                            /* translators: %d is number of pages */
                            __('Duplicate SEO title (%d pages)', 'wp-seo-next'),
                            $count
                        ),
                        'suggestion'     => __('Make the SEO titles unique across pages.', 'wp-seo-next'),
                        'details'        => wp_json_encode([
                            'group_size' => $count,
                            'all_ids'    => array_values($ids),
                        ]),
                        'detected_at'    => current_time('mysql'),
                        'updated_at'     => current_time('mysql'),
                    ];

                    $this->issues->insert($issueRow);
                    $this->scanIssues->insert($scanId, $issueRow);
                    $issuesTotal++;
                    $counts[self::ISSUE_DUPLICATE_TITLE] = (int) ($counts[self::ISSUE_DUPLICATE_TITLE] ?? 0) + 1;
                }
            }

            // Terms.
            if ($includeTerms) {
                $publicTaxonomies = get_taxonomies(['public' => true], 'names');
                if (is_array($publicTaxonomies)) {
                    foreach ($publicTaxonomies as $tax) {
                        $tax = (string) $tax;
                        // Skip internal-ish taxonomy.
                        if ($tax === 'post_format') {
                            continue;
                        }

                        $offset = 0;
                        $chunk  = 200;

                        do {
                            $termIds = get_terms([
                                'taxonomy'   => $tax,
                                'hide_empty' => false,
                                'fields'     => 'ids',
                                'number'     => $chunk,
                                'offset'     => $offset,
                            ]);

                            if (is_wp_error($termIds) || !is_array($termIds) || empty($termIds)) {
                                break;
                            }

                            foreach ($termIds as $termId) {
                                $termId = (int) $termId;
                                if ($termId <= 0) {
                                    continue;
                                }

                                $scannedTerms++;

                                $termIssues = $this->auditTerm($termId, $tax);
                                foreach ($termIssues as $issue) {
                                    $this->issues->insert($issue);
                                    $this->scanIssues->insert($scanId, $issue);
                                    $issuesTotal++;

                                    $code = (string) ($issue['issue_code'] ?? '');
                                    if ($code !== '') {
                                        $counts[$code] = (int) ($counts[$code] ?? 0) + 1;
                                    }
                                }
                            }

                            $offset += $chunk;
                        } while (count($termIds) === $chunk);
                    }
                }
            }



            // Sitemap sanity checks (News/Video).
            if ($this->newsSitemapEnabled) {
                if ($this->newsEligible < 1) {
                    $sUrl = home_url('/news-sitemap.xml');
                    $row = $this->makeIssueRow(
                        'system',
                        0,
                        'sitemap',
                        __('News sitemap', 'wp-seo-next'),
                        (string) $sUrl,
                        self::ISSUE_NEWS_SITEMAP_EMPTY,
                        self::SEVERITY_LOW,
                        __('News sitemap is empty', 'wp-seo-next'),
                        __('Publish at least one indexable post in the last 48 hours (or disable News sitemap in Settings → Sitemap).', 'wp-seo-next'),
                        [
                            'window_hours' => 48,
                            'post_types' => $this->newsPostTypes,
                        ]
                    );
                    $this->issues->insert($row);
                    $this->scanIssues->insert($scanId, $row);
                    $issuesTotal++;
                    $counts[self::ISSUE_NEWS_SITEMAP_EMPTY] = (int) ($counts[self::ISSUE_NEWS_SITEMAP_EMPTY] ?? 0) + 1;
                }
            }

            if ($this->videoSitemapEnabled) {
                if ($this->videoEligible < 1) {
                    $sUrl = home_url('/video-sitemap.xml');
                    $msg = $this->videoCandidates > 0
                        ? __('Video sitemap has no valid entries', 'wp-seo-next')
                        : __('Video sitemap has no entries', 'wp-seo-next');
                    $suggest = $this->videoCandidates > 0
                        ? __('Fix the video meta issues on pages with a Video URL (thumbnail/title/description).', 'wp-seo-next')
                        : __('Set a Video URL on pages that contain videos (or disable Video sitemap in Settings → Sitemap).', 'wp-seo-next');

                    $row = $this->makeIssueRow(
                        'system',
                        0,
                        'sitemap',
                        __('Video sitemap', 'wp-seo-next'),
                        (string) $sUrl,
                        self::ISSUE_VIDEO_SITEMAP_EMPTY,
                        self::SEVERITY_LOW,
                        $msg,
                        $suggest,
                        [
                            'candidates' => $this->videoCandidates,
                            'eligible' => $this->videoEligible,
                            'mode' => $this->videoMode,
                        ]
                    );
                    $this->issues->insert($row);
                    $this->scanIssues->insert($scanId, $row);
                    $issuesTotal++;
                    $counts[self::ISSUE_VIDEO_SITEMAP_EMPTY] = (int) ($counts[self::ISSUE_VIDEO_SITEMAP_EMPTY] ?? 0) + 1;
                }
            }

            // Local SEO sanity checks (NAP consistency / required fields).
            $localIssues = $this->auditLocal();
            foreach ($localIssues as $issue) {
                $this->issues->insert($issue);
                $this->scanIssues->insert($scanId, $issue);
                $issuesTotal++;

                $code = (string) ($issue['issue_code'] ?? '');
                if ($code !== '') {
                    $counts[$code] = (int) ($counts[$code] ?? 0) + 1;
                }
            }

            $summary = [
                'scan_id'      => $scanId,
                'started_at'   => $startedAt,
                'finished_at'  => time(),
                'duration_sec' => max(0, time() - $startedAt),
                'scanned_posts' => $scannedPosts,
                'scanned_terms' => $scannedTerms,
                'issues_total'  => $issuesTotal,
                'issues_by_code' => $counts,
            ];

            update_option('wpsn_audit_last_scan', $summary, false);

            // Mark scan completed in history.
            $this->scans->finishScan($scanId, $summary, 'completed');

            // Retention: keep last N scans.
            $keep = (int) $this->settings->get('audit.history_keep_scans', 20);
            if ($keep < 0) {
                $keep = 0;
            }
            if ($keep > 500) {
                $keep = 500;
            }
            if ($keep > 0) {
                $this->scans->purgeKeepLast($keep);
            }

            $this->logger->info('Audit scan completed', $summary);

            return $summary;
        } catch (\Throwable $e) {
            if ($scanId > 0) {
                $this->scans->markFailed($scanId, $e->getMessage());
            }
            $this->logger->error('Audit scan failed: ' . $e->getMessage());
            throw $e;
        }
    }

    /**
     * Local SEO sanity checks.
     *
     * @return array<int, array<string, mixed>>
     */
    /**
     * System-level Local SEO checks (Location CPT / Local settings).
     *
     * Public so async scan finalizer can reuse the same logic.
     *
     * @return array<int, array<string, mixed>>
     */
    public function auditLocal(): array
    {
        if (!$this->settings->moduleEnabled('local_seo', false)) {
            return [];
        }
        if (!post_type_exists(LocalSEOModule::CPT_LOCATION)) {
            return [];
        }

        $schemaEnabled = (bool) $this->settings->get('local.schema.enabled', true);
        $showOnHome = (bool) $this->settings->get('local.schema.show_on_home', false);
        $kmlEnabled = (bool) $this->settings->get('local.kml.enabled', true);
        $locSitemapEnabled = (bool) $this->settings->get('local.locations_sitemap.enabled', true);

        if (!$schemaEnabled && !$kmlEnabled && !$locSitemapEnabled) {
            return [];
        }

        $out = [];

        // Settings-level checks (only required if we output LocalBusiness on home).
        if ($schemaEnabled && $showOnHome) {
            $b = $this->settings->get('local.business', []);
            $b = is_array($b) ? $b : [];

            $primaryId = (int) $this->settings->get('local.primary_location_id', 0);
            $usePrimary = $primaryId > 0
                && get_post_type($primaryId) === LocalSEOModule::CPT_LOCATION
                && get_post_status($primaryId) === 'publish';

            $source = $usePrimary ? 'primary_location' : 'settings';
            $biz = $usePrimary ? $this->resolveBusinessDataForAudit($primaryId, $b) : $b;

            $missing = [];
            $need = [
                'name' => __('Business name', 'wp-seo-next'),
                'telephone' => __('Telephone', 'wp-seo-next'),
                'street_address' => __('Street address', 'wp-seo-next'),
                'address_locality' => __('City / Locality', 'wp-seo-next'),
                'address_region' => __('Region / State', 'wp-seo-next'),
                'postal_code' => __('Postal code', 'wp-seo-next'),
                'address_country' => __('Country', 'wp-seo-next'),
            ];

            foreach ($need as $key => $label) {
                $val = isset($biz[$key]) ? trim((string) $biz[$key]) : '';
                if ($val === '') {
                    $missing[] = (string) $label;
                }
            }

            if (!empty($missing)) {
                $url = $usePrimary
                    ? (string) get_edit_post_link($primaryId, '')
                    : (string) admin_url('admin.php?page=wp-seo-next');

                $title = $usePrimary
                    ? __('Primary location', 'wp-seo-next')
                    : __('Local SEO', 'wp-seo-next');

                $summary = $usePrimary
                    ? __('LocalBusiness schema is enabled on the home page, but the Primary location is missing business info', 'wp-seo-next')
                    : __('LocalBusiness schema is enabled on the home page, but business info is incomplete', 'wp-seo-next');

                $out[] = $this->makeIssueRow(
                    'system',
                    0,
                    'local',
                    (string) $title,
                    (string) $url,
                    self::ISSUE_LOCAL_HOME_MISSING_BUSINESS_INFO,
                    self::SEVERITY_MEDIUM,
                    (string) $summary,
                    sprintf(
                        /* translators: %s is missing fields */
                        __('Fill in the missing Local SEO fields: %s', 'wp-seo-next'),
                        implode(', ', $missing)
                    ),
                    [
                        'missing' => $missing,
                        'context' => $source,
                        'primary_location_id' => $usePrimary ? $primaryId : 0,
                    ]
                );
            }
        }

        // Locations.
        $ids = get_posts([
            'post_type' => LocalSEOModule::CPT_LOCATION,
            'post_status' => 'publish',
            'fields' => 'ids',
            'posts_per_page' => -1,
            'orderby' => 'ID',
            'order' => 'ASC',
            'no_found_rows' => true,
            'suppress_filters' => true,
        ]);
        $ids = is_array($ids) ? array_values(array_map('intval', $ids)) : [];
        $ids = array_values(array_filter($ids, static fn($i) => $i > 0));
        $count = count($ids);

        if ($count < 1) {
            return $out;
        }

        $b = $this->settings->get('local.business', []);
        $b = is_array($b) ? $b : [];

        foreach ($ids as $id) {
            $url = get_permalink($id);
            $url = is_string($url) ? $url : '';

            $title = get_the_title($id);
            $title = is_string($title) ? $title : '';

            $missing = [];

            // For multi-location sites, require per-location address + phone.
            // For a single location, allow falling back to Settings fields.
            $req = [
                'telephone' => [LocalMetaKeys::TELEPHONE, 'telephone', __('Telephone', 'wp-seo-next')],
                'street_address' => [LocalMetaKeys::STREET_ADDRESS, 'street_address', __('Street address', 'wp-seo-next')],
                'address_locality' => [LocalMetaKeys::ADDRESS_LOCALITY, 'address_locality', __('City / Locality', 'wp-seo-next')],
                'address_region' => [LocalMetaKeys::ADDRESS_REGION, 'address_region', __('Region / State', 'wp-seo-next')],
                'postal_code' => [LocalMetaKeys::POSTAL_CODE, 'postal_code', __('Postal code', 'wp-seo-next')],
                'address_country' => [LocalMetaKeys::ADDRESS_COUNTRY, 'address_country', __('Country', 'wp-seo-next')],
            ];

            foreach ($req as $key => $tuple) {
                [$metaKey, $settingsKey, $label] = $tuple;
                $val = trim((string) get_post_meta($id, (string) $metaKey, true));
                if ($val === '' && $count === 1) {
                    $val = isset($b[$settingsKey]) ? trim((string) $b[$settingsKey]) : '';
                }
                if ($val === '') {
                    $missing[] = (string) $label;
                }
            }

            if (!empty($missing)) {
                $out[] = $this->makeIssueRow(
                    'post',
                    $id,
                    LocalSEOModule::CPT_LOCATION,
                    (string) $title,
                    (string) $url,
                    self::ISSUE_LOCAL_LOCATION_MISSING_FIELDS,
                    self::SEVERITY_MEDIUM,
                    __('Location is missing required NAP fields', 'wp-seo-next'),
                    sprintf(
                        /* translators: %s is missing fields */
                        __('Fill in: %s', 'wp-seo-next'),
                        implode(', ', $missing)
                    ),
                    [
                        'missing' => $missing,
                    ]
                );
            }

            if ($kmlEnabled) {
                $lat = trim((string) get_post_meta($id, LocalMetaKeys::LATITUDE, true));
                $lng = trim((string) get_post_meta($id, LocalMetaKeys::LONGITUDE, true));
                if (($lat === '' || $lng === '') && $count === 1) {
                    $lat = $lat !== '' ? $lat : (isset($b['latitude']) ? trim((string) $b['latitude']) : '');
                    $lng = $lng !== '' ? $lng : (isset($b['longitude']) ? trim((string) $b['longitude']) : '');
                }
                if ($lat === '' || $lng === '') {
                    $out[] = $this->makeIssueRow(
                        'post',
                        $id,
                        LocalSEOModule::CPT_LOCATION,
                        (string) $title,
                        (string) $url,
                        self::ISSUE_LOCAL_LOCATION_MISSING_GEO,
                        self::SEVERITY_LOW,
                        __('Location is missing coordinates (lat/lng)', 'wp-seo-next'),
                        __('Set latitude and longitude to include this location in locations.kml.', 'wp-seo-next')
                    );
                }
            }

            // NAP mismatch check for single-location sites.
            if ($count === 1) {
                $settingsNap = $this->normalizeNap([
                    'name' => isset($b['name']) ? (string) $b['name'] : '',
                    'telephone' => isset($b['telephone']) ? (string) $b['telephone'] : '',
                    'address' => implode(', ', array_filter([
                        isset($b['street_address']) ? (string) $b['street_address'] : '',
                        isset($b['address_locality']) ? (string) $b['address_locality'] : '',
                        isset($b['address_region']) ? (string) $b['address_region'] : '',
                        isset($b['postal_code']) ? (string) $b['postal_code'] : '',
                        isset($b['address_country']) ? (string) $b['address_country'] : '',
                    ], static fn($x) => trim((string) $x) !== '')),
                ]);

                $locNap = $this->normalizeNap([
                    'name' => (string) get_post_meta($id, LocalMetaKeys::NAME, true),
                    'telephone' => (string) get_post_meta($id, LocalMetaKeys::TELEPHONE, true),
                    'address' => implode(', ', array_filter([
                        (string) get_post_meta($id, LocalMetaKeys::STREET_ADDRESS, true),
                        (string) get_post_meta($id, LocalMetaKeys::ADDRESS_LOCALITY, true),
                        (string) get_post_meta($id, LocalMetaKeys::ADDRESS_REGION, true),
                        (string) get_post_meta($id, LocalMetaKeys::POSTAL_CODE, true),
                        (string) get_post_meta($id, LocalMetaKeys::ADDRESS_COUNTRY, true),
                    ], static fn($x) => trim((string) $x) !== '')),
                ]);

                // Only compare if both sides have data.
                if ($settingsNap !== '' && $locNap !== '' && $settingsNap !== $locNap) {
                    $out[] = $this->makeIssueRow(
                        'post',
                        $id,
                        LocalSEOModule::CPT_LOCATION,
                        (string) $title,
                        (string) $url,
                        self::ISSUE_LOCAL_NAP_MISMATCH_SETTINGS,
                        self::SEVERITY_LOW,
                        __('Location NAP differs from Local SEO settings', 'wp-seo-next'),
                        __('If this is a single-location site, keep NAP consistent between Settings → Local SEO and the Location post.', 'wp-seo-next'),
                        [
                            'settings' => $settingsNap,
                            'location' => $locNap,
                        ]
                    );
                }
            }
        }

        // Canonical points outside the site.
        if ($canon !== '') {
            $canonHost = wp_parse_url($canon, PHP_URL_HOST);
            $siteHost  = wp_parse_url(home_url('/'), PHP_URL_HOST);
            if (is_string($canonHost) && is_string($siteHost) && $canonHost !== '' && $siteHost !== '' && strtolower($canonHost) !== strtolower($siteHost)) {
                $out[] = $this->makeIssueRow(
                    'term',
                    $termId,
                    $taxonomy,
                    $title,
                    $url,
                    self::ISSUE_CANONICAL_EXTERNAL,
                    self::SEVERITY_LOW,
                    __('Canonical points to another domain', 'wp-seo-next'),
                    __('Confirm this is intentional. External canonicals can remove your archive from results.', 'wp-seo-next'),
                    [
                        'canonical' => $canon,
                    ]
                );
            }
        }

        // Canonical differs from the current term URL (internal canonical).
        if ($canon !== '') {
            $urlKey = SiteCrawlUrlKey::normalize($url);
            $canonKey = SiteCrawlUrlKey::normalize($canon);
            if ($urlKey !== '' && $canonKey !== '' && $urlKey !== $canonKey) {
                $out[] = $this->makeIssueRow(
                    'term',
                    $termId,
                    $taxonomy,
                    $title,
                    $url,
                    self::ISSUE_CANONICAL_MISMATCH,
                    self::SEVERITY_MEDIUM,
                    __('Canonical differs from the page URL', 'wp-seo-next'),
                    __('Review the canonical override. If this is not intentional, clear it so the canonical matches the term URL.', 'wp-seo-next'),
                    [
                        'canonical' => $canon,
                        'raw' => $rawCanonical,
                        'url' => $url,
                    ]
                );
            }
        }


        return $out;
    }

    /**
     * @param array{name:string,telephone:string,address:string} $nap
     */
    private function normalizeNap(array $nap): string
    {
        $name = isset($nap['name']) ? (string) $nap['name'] : '';
        $tel = isset($nap['telephone']) ? (string) $nap['telephone'] : '';
        $addr = isset($nap['address']) ? (string) $nap['address'] : '';

        $name = trim(wp_strip_all_tags($name));
        if (function_exists('mb_strtolower')) {
            $name = (string) mb_strtolower($name);
        } else {
            $name = strtolower($name);
        }
        $tel = preg_replace('/[^0-9+]/', '', $tel) ?? '';
        $addr = trim(wp_strip_all_tags($addr));
        if (function_exists('mb_strtolower')) {
            $addr = (string) mb_strtolower($addr);
        } else {
            $addr = strtolower($addr);
        }

        if ($name === '' && $tel === '' && $addr === '') {
            return '';
        }

        return $name . '|' . $tel . '|' . $addr;
    }

    /**
     * Resolve business fields similarly to LocalSEO schema output:
     * settings as a base + location post meta overrides.
     *
     * @param array<string,mixed> $settingsBusiness
     * @return array<string,string>
     */
    private function resolveBusinessDataForAudit(int $locationId, array $settingsBusiness): array
    {
        $b = $settingsBusiness;

        $data = [
            'name' => isset($b['name']) ? trim((string) $b['name']) : '',
            'telephone' => isset($b['telephone']) ? trim((string) $b['telephone']) : '',
            'email' => isset($b['email']) ? trim((string) $b['email']) : '',
            'street_address' => isset($b['street_address']) ? trim((string) $b['street_address']) : '',
            'address_locality' => isset($b['address_locality']) ? trim((string) $b['address_locality']) : '',
            'address_region' => isset($b['address_region']) ? trim((string) $b['address_region']) : '',
            'postal_code' => isset($b['postal_code']) ? trim((string) $b['postal_code']) : '',
            'address_country' => isset($b['address_country']) ? trim((string) $b['address_country']) : '',
            'price_range' => isset($b['price_range']) ? trim((string) $b['price_range']) : '',
            'latitude' => isset($b['latitude']) ? trim((string) $b['latitude']) : '',
            'longitude' => isset($b['longitude']) ? trim((string) $b['longitude']) : '',
            'opening_hours' => isset($b['opening_hours']) ? trim((string) $b['opening_hours']) : '',
        ];

        if ($locationId > 0) {
            $title = get_the_title($locationId);
            $title = is_string($title) ? trim($title) : '';

            $name = trim((string) get_post_meta($locationId, LocalMetaKeys::NAME, true));
            if ($name === '') {
                $name = $title;
            }
            if ($name !== '') {
                $data['name'] = $name;
            }

            $overrides = [
                'telephone' => LocalMetaKeys::TELEPHONE,
                'email' => LocalMetaKeys::EMAIL,
                'street_address' => LocalMetaKeys::STREET_ADDRESS,
                'address_locality' => LocalMetaKeys::ADDRESS_LOCALITY,
                'address_region' => LocalMetaKeys::ADDRESS_REGION,
                'postal_code' => LocalMetaKeys::POSTAL_CODE,
                'address_country' => LocalMetaKeys::ADDRESS_COUNTRY,
                'latitude' => LocalMetaKeys::LATITUDE,
                'longitude' => LocalMetaKeys::LONGITUDE,
                'price_range' => LocalMetaKeys::PRICE_RANGE,
                'opening_hours' => LocalMetaKeys::OPENING_HOURS,
            ];

            foreach ($overrides as $key => $metaKey) {
                $v = trim((string) get_post_meta($locationId, $metaKey, true));
                if ($v !== '') {
                    $data[$key] = $v;
                }
            }
        }

        return $data;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    /**
     * Audit a single post.
     *
     * Public so async scan jobs can run in batches.
     *
     * @return array<int, array<string, mixed>>
     */
    public function auditPost(int $postId, string $postType, bool $checkSocialImage): array
    {
        $url = get_permalink($postId);
        if (!is_string($url) || $url === '') {
            return [];
        }

        $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_SINGULAR, $postId, $postType, $url, 1);
        $meta = $this->metaForContextCached($ctx);

        $out = [];

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

        $titleInfo = AuditMetaSignal::analyzeRenderedMeta($title);
        $descInfo = AuditMetaSignal::analyzeRenderedMeta($desc);

        if (!empty($titleInfo['blank'])) {
            $titleReason = (string) ($titleInfo['reason'] ?? AuditMetaSignal::REASON_BLANK);
            $titleMessage = $titleReason === AuditMetaSignal::REASON_BLANK
                ? __('Missing SEO title', 'wp-seo-next')
                : __('SEO title renders as empty or broken', 'wp-seo-next');

            $out[] = $this->makeIssueRow(
                'post',
                $postId,
                $postType,
                (string) get_the_title($postId),
                $url,
                self::ISSUE_MISSING_TITLE,
                self::SEVERITY_HIGH,
                $titleMessage,
                __('Set an SEO title override or adjust the global title template.', 'wp-seo-next'),
                [
                    'reason' => $titleReason,
                    'rendered_title' => (string) ($titleInfo['normalized'] ?? ''),
                ]
            );
        }

        if (empty($titleInfo['blank'])) {
            $out = array_merge($out, $this->buildRenderedMetaQualityIssues(
                'post',
                $postId,
                $postType,
                (string) get_the_title($postId),
                $url,
                'title',
                $title
            ));
        }

        if (!empty($descInfo['blank'])) {
            $descReason = (string) ($descInfo['reason'] ?? AuditMetaSignal::REASON_BLANK);
            $descMessage = $descReason === AuditMetaSignal::REASON_BLANK
                ? __('Missing meta description', 'wp-seo-next')
                : __('Meta description renders as empty or broken', 'wp-seo-next');

            $out[] = $this->makeIssueRow(
                'post',
                $postId,
                $postType,
                (string) get_the_title($postId),
                $url,
                self::ISSUE_MISSING_DESCRIPTION,
                self::SEVERITY_MEDIUM,
                $descMessage,
                __('Add a meta description (or excerpt) so search results look better.', 'wp-seo-next'),
                [
                    'reason' => $descReason,
                    'rendered_description' => (string) ($descInfo['normalized'] ?? ''),
                ]
            );
        }

        if (empty($descInfo['blank'])) {
            $out = array_merge($out, $this->buildRenderedMetaQualityIssues(
                'post',
                $postId,
                $postType,
                (string) get_the_title($postId),
                $url,
                'description',
                $desc
            ));
        }

        $noindex = !empty($meta->robots['noindex']);
        if ($noindex) {
            $out[] = $this->makeIssueRow(
                'post',
                $postId,
                $postType,
                (string) get_the_title($postId),
                $url,
                self::ISSUE_NOINDEX,
                self::SEVERITY_HIGH,
                __('Noindex on a public page', 'wp-seo-next'),
                __('If this page should rank, set Robots to Index (remove noindex).', 'wp-seo-next')
            );
        }

        // Canonical validity (only if user set override).
        $rawCanonical = (string) get_post_meta($postId, MetaKeys::CANONICAL, true);
        $rawCanonical = trim($rawCanonical);
        $canon = is_string($meta->canonical) ? trim($meta->canonical) : '';

        if ($rawCanonical !== '' && $canon === '') {
            $out[] = $this->makeIssueRow(
                'post',
                $postId,
                $postType,
                (string) get_the_title($postId),
                $url,
                self::ISSUE_CANONICAL_INVALID,
                self::SEVERITY_HIGH,
                __('Invalid canonical URL', 'wp-seo-next'),
                __('Fix the canonical field (it must be a valid URL or a site-relative path).', 'wp-seo-next'),
                [
                    'raw' => $rawCanonical,
                ]
            );
        }

        // Canonical points outside the site.
        if ($canon !== '') {
            $canonHost = wp_parse_url($canon, PHP_URL_HOST);
            $siteHost  = wp_parse_url(home_url('/'), PHP_URL_HOST);
            if (is_string($canonHost) && is_string($siteHost) && $canonHost !== '' && $siteHost !== '' && strtolower($canonHost) !== strtolower($siteHost)) {
                $out[] = $this->makeIssueRow(
                    'post',
                    $postId,
                    $postType,
                    (string) get_the_title($postId),
                    $url,
                    self::ISSUE_CANONICAL_EXTERNAL,
                    self::SEVERITY_LOW,
                    __('Canonical points to another domain', 'wp-seo-next'),
                    __('Confirm this is intentional. External canonicals can remove your page from results.', 'wp-seo-next'),
                    [
                        'canonical' => $canon,
                    ]
                );
            }
        }

        // Canonical differs from the current page URL (internal canonical).
        if ($canon !== '') {
            $urlKey = SiteCrawlUrlKey::normalize($url);
            $canonKey = SiteCrawlUrlKey::normalize($canon);
            if ($urlKey !== '' && $canonKey !== '' && $urlKey !== $canonKey) {
                $out[] = $this->makeIssueRow(
                    'post',
                    $postId,
                    $postType,
                    (string) get_the_title($postId),
                    $url,
                    self::ISSUE_CANONICAL_MISMATCH,
                    self::SEVERITY_MEDIUM,
                    __('Canonical differs from the page URL', 'wp-seo-next'),
                    __('Review the canonical override. If this is not intentional, clear it so the canonical matches the page URL.', 'wp-seo-next'),
                    [
                        'canonical' => $canon,
                        'raw' => $rawCanonical,
                        'url' => $url,
                    ]
                );
            }
        }


        // Social image missing (resolved OG/Twitter image).
        if ($checkSocialImage) {
            $ogImage = is_string($meta->ogImage) ? trim($meta->ogImage) : '';
            if ($ogImage === '') {
                $out[] = $this->makeIssueRow(
                    'post',
                    $postId,
                    $postType,
                    (string) get_the_title($postId),
                    $url,
                    self::ISSUE_SOCIAL_MISSING_IMAGE,
                    self::SEVERITY_LOW,
                    __('Missing social share image', 'wp-seo-next'),
                    __('Set an OG image override, a Featured Image, or configure a default OG image in Settings → Social.', 'wp-seo-next')
                );
            }
        }



        // News sitemap validation: ensure recent indexable posts have a post title.
        if ($this->newsSitemapEnabled && $this->newsSinceTs > 0 && in_array($postType, $this->newsPostTypes, true) && !$noindex) {
            $ts = get_post_time('U', true, $postId);
            $ts = is_numeric($ts) ? (int) $ts : 0;
            if ($ts >= $this->newsSinceTs) {
                $this->newsEligible++;

                $postTitle = trim(wp_strip_all_tags((string) get_the_title($postId)));
                if ($postTitle === '') {
                    $out[] = $this->makeIssueRow(
                        'post',
                        $postId,
                        $postType,
                        (string) get_the_title($postId),
                        $url,
                        self::ISSUE_NEWS_SITEMAP_MISSING_POST_TITLE,
                        self::SEVERITY_MEDIUM,
                        __('Missing post title for News sitemap', 'wp-seo-next'),
                        __('Set a post title (News sitemap uses the post title, not the SEO title).', 'wp-seo-next')
                    );
                }
            }
        }

        // Video sitemap validation: if a page has a Video URL, it must have required fields to be included.
        if ($this->videoSitemapEnabled && !$noindex) {
            $rawVideoUrl = (string) get_post_meta($postId, MetaKeys::VIDEO_URL, true);
            $rawVideoUrl = trim($rawVideoUrl);

            if ($rawVideoUrl !== '') {
                $this->videoCandidates++;

                $cleanVideoUrl = \WPSN\Modules\Sitemap\SitemapModule::sanitizeVideoUrl($rawVideoUrl);
                if ($cleanVideoUrl === '') {
                    $out[] = $this->makeIssueRow(
                        'post',
                        $postId,
                        $postType,
                        (string) get_the_title($postId),
                        $url,
                        self::ISSUE_VIDEO_SITEMAP_INVALID_URL,
                        self::SEVERITY_HIGH,
                        __('Invalid video URL (Video sitemap)', 'wp-seo-next'),
                        __('Fix the Video URL field (must be a valid URL; site-relative paths are allowed).', 'wp-seo-next'),
                        ['raw' => $rawVideoUrl]
                    );
                }

                // Resolve title/description/thumbnail similarly to the Video sitemap builder.
                $ok = true;

                $vTitle = trim((string) get_post_meta($postId, MetaKeys::VIDEO_TITLE, true));
                if ($vTitle === '') {
                    $vTitle = trim(wp_strip_all_tags((string) get_the_title($postId)));
                }
                if ($vTitle === '') {
                    $ok = false;
                    $out[] = $this->makeIssueRow(
                        'post',
                        $postId,
                        $postType,
                        (string) get_the_title($postId),
                        $url,
                        self::ISSUE_VIDEO_SITEMAP_MISSING_TITLE,
                        self::SEVERITY_MEDIUM,
                        __('Missing video title (Video sitemap)', 'wp-seo-next'),
                        __('Set Video Title (or ensure the post has a title).', 'wp-seo-next')
                    );
                }

                $vDesc = trim((string) get_post_meta($postId, MetaKeys::VIDEO_DESCRIPTION, true));
                if ($vDesc === '') {
                    $vDesc = trim((string) get_post_field('post_excerpt', $postId));
                }
                if ($vDesc === '') {
                    $content = (string) get_post_field('post_content', $postId);
                    $content = wp_strip_all_tags($content);
                    $content = trim(preg_replace('/\s+/u', ' ', $content) ?? '');
                    if ($content !== '') {
                        $vDesc = wp_trim_words($content, 40, '…');
                    }
                }
                if ($vDesc === '') {
                    $ok = false;
                    $out[] = $this->makeIssueRow(
                        'post',
                        $postId,
                        $postType,
                        (string) get_the_title($postId),
                        $url,
                        self::ISSUE_VIDEO_SITEMAP_MISSING_DESCRIPTION,
                        self::SEVERITY_LOW,
                        __('Missing video description (Video sitemap)', 'wp-seo-next'),
                        __('Set Video Description (or add an excerpt/content) so the Video sitemap entry is valid.', 'wp-seo-next')
                    );
                }

                $vThumb = trim((string) get_post_meta($postId, MetaKeys::VIDEO_THUMBNAIL, true));
                $vThumb = $vThumb !== '' ? esc_url_raw($vThumb) : '';

                if ($vThumb === '' && $this->videoMode === 'youtube_auto') {
                    $ytId = $this->extractYouTubeIdFromUrl($rawVideoUrl);
                    if ($ytId !== '') {
                        $vThumb = 'https://i.ytimg.com/vi/' . rawurlencode($ytId) . '/hqdefault.jpg';
                    }
                }

                if ($vThumb === '' && $this->videoIncludeFeaturedThumbnail) {
                    $thumbId = get_post_thumbnail_id($postId);
                    if ($thumbId) {
                        $img = wp_get_attachment_image_src((int) $thumbId, 'full');
                        if (is_array($img) && !empty($img[0])) {
                            $vThumb = esc_url_raw((string) $img[0]);
                        }
                    }
                }

                if ($vThumb === '') {
                    $ok = false;
                    $out[] = $this->makeIssueRow(
                        'post',
                        $postId,
                        $postType,
                        (string) get_the_title($postId),
                        $url,
                        self::ISSUE_VIDEO_SITEMAP_MISSING_THUMBNAIL,
                        self::SEVERITY_MEDIUM,
                        __('Missing video thumbnail (Video sitemap)', 'wp-seo-next'),
                        __('Set a Thumbnail URL, enable Featured Image fallback, or use YouTube Auto mode.', 'wp-seo-next')
                    );
                }

                $rawDur = get_post_meta($postId, MetaKeys::VIDEO_DURATION, true);
                $rawDur = is_scalar($rawDur) ? (string) $rawDur : '';
                $rawDur = trim($rawDur);
                if ($rawDur !== '' && !is_numeric($rawDur)) {
                    $out[] = $this->makeIssueRow(
                        'post',
                        $postId,
                        $postType,
                        (string) get_the_title($postId),
                        $url,
                        self::ISSUE_VIDEO_SITEMAP_INVALID_DURATION,
                        self::SEVERITY_LOW,
                        __('Invalid video duration (Video sitemap)', 'wp-seo-next'),
                        __('Duration must be a number of seconds (e.g. 120).', 'wp-seo-next'),
                        ['raw' => $rawDur]
                    );
                }

                if ($cleanVideoUrl !== '' && $ok) {
                    $this->videoEligible++;
                }
            }
        }
        return $out;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    /**
     * Audit a single term.
     *
     * Public so async scan jobs can run in batches.
     *
     * @return array<int, array<string, mixed>>
     */
    public function auditTerm(int $termId, string $taxonomy): array
    {
        $url = get_term_link($termId, $taxonomy);
        if (is_wp_error($url) || !is_string($url) || $url === '') {
            return [];
        }

        $term = get_term($termId, $taxonomy);
        $title = $term && !is_wp_error($term) ? (string) $term->name : '';

        $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_TERM, $termId, $taxonomy, $url, 1);
        $meta = $this->metaForContextCached($ctx);

        $out = [];

        $seoTitle = is_string($meta->title) ? (string) $meta->title : '';
        $desc  = is_string($meta->description) ? (string) $meta->description : '';

        $titleInfo = AuditMetaSignal::analyzeRenderedMeta($seoTitle);
        $descInfo = AuditMetaSignal::analyzeRenderedMeta($desc);

        if (!empty($titleInfo['blank'])) {
            $titleReason = (string) ($titleInfo['reason'] ?? AuditMetaSignal::REASON_BLANK);
            $titleMessage = $titleReason === AuditMetaSignal::REASON_BLANK
                ? __('Missing SEO title', 'wp-seo-next')
                : __('SEO title renders as empty or broken', 'wp-seo-next');

            $out[] = $this->makeIssueRow(
                'term',
                $termId,
                $taxonomy,
                $title,
                $url,
                self::ISSUE_MISSING_TITLE,
                self::SEVERITY_HIGH,
                $titleMessage,
                __('Set a term SEO title override or adjust templates.', 'wp-seo-next'),
                [
                    'reason' => $titleReason,
                    'rendered_title' => (string) ($titleInfo['normalized'] ?? ''),
                ]
            );
        }

        if (empty($titleInfo['blank'])) {
            $out = array_merge($out, $this->buildRenderedMetaQualityIssues(
                'term',
                $termId,
                $taxonomy,
                $title,
                $url,
                'title',
                $seoTitle
            ));
        }

        if (!empty($descInfo['blank'])) {
            $descReason = (string) ($descInfo['reason'] ?? AuditMetaSignal::REASON_BLANK);
            $descMessage = $descReason === AuditMetaSignal::REASON_BLANK
                ? __('Missing meta description', 'wp-seo-next')
                : __('Meta description renders as empty or broken', 'wp-seo-next');

            $out[] = $this->makeIssueRow(
                'term',
                $termId,
                $taxonomy,
                $title,
                $url,
                self::ISSUE_MISSING_DESCRIPTION,
                self::SEVERITY_MEDIUM,
                $descMessage,
                __('Add a description for this term so search results look better.', 'wp-seo-next'),
                [
                    'reason' => $descReason,
                    'rendered_description' => (string) ($descInfo['normalized'] ?? ''),
                ]
            );
        }

        if (empty($descInfo['blank'])) {
            $out = array_merge($out, $this->buildRenderedMetaQualityIssues(
                'term',
                $termId,
                $taxonomy,
                $title,
                $url,
                'description',
                $desc
            ));
        }

        $noindex = !empty($meta->robots['noindex']);
        if ($noindex) {
            $out[] = $this->makeIssueRow(
                'term',
                $termId,
                $taxonomy,
                $title,
                $url,
                self::ISSUE_NOINDEX,
                self::SEVERITY_HIGH,
                __('Noindex on a public archive', 'wp-seo-next'),
                __('If this term archive should rank, set Robots to Index.', 'wp-seo-next')
            );
        }

        // Canonical validity (only if override set).
        $rawCanonical = (string) get_term_meta($termId, MetaKeys::CANONICAL, true);
        $rawCanonical = trim($rawCanonical);
        $canon = is_string($meta->canonical) ? trim($meta->canonical) : '';

        if ($rawCanonical !== '' && $canon === '') {
            $out[] = $this->makeIssueRow(
                'term',
                $termId,
                $taxonomy,
                $title,
                $url,
                self::ISSUE_CANONICAL_INVALID,
                self::SEVERITY_HIGH,
                __('Invalid canonical URL', 'wp-seo-next'),
                __('Fix the canonical field (it must be a valid URL or a site-relative path).', 'wp-seo-next'),
                [
                    'raw' => $rawCanonical,
                ]
            );
        }

        // Canonical points outside the site.
        if ($canon !== '') {
            $canonHost = wp_parse_url($canon, PHP_URL_HOST);
            $siteHost  = wp_parse_url(home_url('/'), PHP_URL_HOST);
            if (is_string($canonHost) && is_string($siteHost) && $canonHost !== '' && $siteHost !== '' && strtolower($canonHost) !== strtolower($siteHost)) {
                $out[] = $this->makeIssueRow(
                    'term',
                    $termId,
                    $taxonomy,
                    $title,
                    $url,
                    self::ISSUE_CANONICAL_EXTERNAL,
                    self::SEVERITY_LOW,
                    __('Canonical points to another domain', 'wp-seo-next'),
                    __('Confirm this is intentional. External canonicals can remove your archive from results.', 'wp-seo-next'),
                    [
                        'canonical' => $canon,
                    ]
                );
            }
        }

        // Canonical differs from the current term URL (internal canonical).
        if ($canon !== '') {
            $urlKey = SiteCrawlUrlKey::normalize($url);
            $canonKey = SiteCrawlUrlKey::normalize($canon);
            if ($urlKey !== '' && $canonKey !== '' && $urlKey !== $canonKey) {
                $out[] = $this->makeIssueRow(
                    'term',
                    $termId,
                    $taxonomy,
                    $title,
                    $url,
                    self::ISSUE_CANONICAL_MISMATCH,
                    self::SEVERITY_MEDIUM,
                    __('Canonical differs from the page URL', 'wp-seo-next'),
                    __('Review the canonical override. If this is not intentional, clear it so the canonical matches the term URL.', 'wp-seo-next'),
                    [
                        'canonical' => $canon,
                        'raw' => $rawCanonical,
                        'url' => $url,
                    ]
                );
            }
        }


        return $out;
    }

    /**
     * @param array<string, mixed> $details
     * @return array<string, mixed>
     */
    private function makeIssueRow(
        string $objectType,
        int $objectId,
        string $objectSubType,
        string $objectTitle,
        string $objectUrl,
        string $issueCode,
        int $severity,
        string $message,
        string $suggestion,
        array $details = []
    ): array {
        return [
            'object_type'    => $objectType,
            'object_id'      => $objectId,
            'object_subtype' => $objectSubType,
            'object_title'   => $objectTitle,
            'object_url'     => $objectUrl,
            'issue_code'     => $issueCode,
            'severity'       => $severity,
            'message'        => $message,
            'suggestion'     => $suggestion,
            'details'        => !empty($details) ? wp_json_encode($details) : null,
            'detected_at'    => current_time('mysql'),
            'updated_at'     => current_time('mysql'),
        ];
    }

    /**
     * @return array{min:int,max:int}
     */
    private function metaLengthThresholds(string $field): array
    {
        $field = $field === 'description' ? 'description' : 'title';

        if ($field === 'description') {
            $min = (int) apply_filters('wpsn/audit/description_min_len', 50);
            $max = (int) apply_filters('wpsn/audit/description_max_len', 160);

            $min = max(0, min(400, $min));
            $max = max(40, min(400, $max));
            if ($max < $min) {
                $max = $min;
            }

            return [
                'min' => $min,
                'max' => $max,
            ];
        }

        $min = max(0, min(120, (int) apply_filters('wpsn/audit/title_min_len', 20)));
        $max = max(10, min(200, (int) apply_filters('wpsn/audit/title_max_len', 60)));
        if ($max < $min) {
            $max = $min;
        }

        return [
            'min' => $min,
            'max' => $max,
        ];
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function buildRenderedMetaQualityIssues(
        string $objectType,
        int $objectId,
        string $objectSubType,
        string $objectTitle,
        string $objectUrl,
        string $field,
        string $value
    ): array {
        $field = $field === 'description' ? 'description' : 'title';
        $thresholds = $this->metaLengthThresholds($field);
        $quality = AuditMetaSignal::classifyRenderedLength($value, (int) $thresholds['min'], (int) $thresholds['max']);

        $normalized = (string) ($quality['normalized'] ?? '');
        $length = (int) ($quality['length'] ?? 0);
        $detailsKey = $field === 'title' ? 'rendered_title' : 'rendered_description';
        $out = [];

        if (!empty($quality['has_placeholder'])) {
            $out[] = $this->makeIssueRow(
                $objectType,
                $objectId,
                $objectSubType,
                $objectTitle,
                $objectUrl,
                $field === 'title' ? self::ISSUE_TITLE_TEMPLATE_BROKEN : self::ISSUE_DESCRIPTION_TEMPLATE_BROKEN,
                $field === 'title' ? self::SEVERITY_HIGH : self::SEVERITY_MEDIUM,
                $field === 'title'
                    ? __('SEO title contains unresolved template tokens', 'wp-seo-next')
                    : __('Meta description contains unresolved template tokens', 'wp-seo-next'),
                $field === 'title'
                    ? __('Fix the SEO title template or remove unknown placeholders from the override.', 'wp-seo-next')
                    : __('Fix the description template or remove unknown placeholders from the override.', 'wp-seo-next'),
                [
                    'reason' => 'unresolved_placeholder',
                    $detailsKey => $normalized,
                    'length' => $length,
                ]
            );

            return $out;
        }

        $status = (string) ($quality['status'] ?? AuditMetaSignal::LENGTH_OK);
        if ($status === AuditMetaSignal::LENGTH_TOO_SHORT) {
            $out[] = $this->makeIssueRow(
                $objectType,
                $objectId,
                $objectSubType,
                $objectTitle,
                $objectUrl,
                $field === 'title' ? self::ISSUE_TITLE_TOO_SHORT : self::ISSUE_DESCRIPTION_TOO_SHORT,
                self::SEVERITY_LOW,
                $field === 'title'
                    ? __('SEO title is too short', 'wp-seo-next')
                    : __('Meta description is too short', 'wp-seo-next'),
                $field === 'title'
                    ? __('Make the title more descriptive so search results are clearer.', 'wp-seo-next')
                    : __('Expand the description so it explains the page better in search results.', 'wp-seo-next'),
                [
                    $detailsKey => $normalized,
                    'length' => $length,
                    'min' => (int) $thresholds['min'],
                    'max' => (int) $thresholds['max'],
                ]
            );
        } elseif ($status === AuditMetaSignal::LENGTH_TOO_LONG) {
            $out[] = $this->makeIssueRow(
                $objectType,
                $objectId,
                $objectSubType,
                $objectTitle,
                $objectUrl,
                $field === 'title' ? self::ISSUE_TITLE_TOO_LONG : self::ISSUE_DESCRIPTION_TOO_LONG,
                self::SEVERITY_LOW,
                $field === 'title'
                    ? __('SEO title is too long', 'wp-seo-next')
                    : __('Meta description is too long', 'wp-seo-next'),
                $field === 'title'
                    ? __('Shorten the title so it is less likely to truncate in search results.', 'wp-seo-next')
                    : __('Shorten the description so it is less likely to truncate in search results.', 'wp-seo-next'),
                [
                    $detailsKey => $normalized,
                    'length' => $length,
                    'min' => (int) $thresholds['min'],
                    'max' => (int) $thresholds['max'],
                ]
            );
        }

        return $out;
    }

    private function getSeoTitleForPost(int $postId, string $postType): string
    {
        $url = get_permalink($postId);
        if (!is_string($url) || $url === '') {
            return '';
        }
        $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_SINGULAR, $postId, $postType, $url, 1);
        $meta = $this->metaForContextCached($ctx);

        return is_string($meta->title) ? (string) $meta->title : '';
    }

    private function getSeoDescriptionForPost(int $postId, string $postType): string
    {
        $url = get_permalink($postId);
        if (!is_string($url) || $url === '') {
            return '';
        }
        $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_SINGULAR, $postId, $postType, $url, 1);
        $meta = $this->metaForContextCached($ctx);

        return is_string($meta->description) ? (string) $meta->description : '';
    }

    private function getSeoTitleForTerm(int $termId, string $taxonomy): string
    {
        $url = get_term_link($termId, $taxonomy);
        if (is_wp_error($url) || !is_string($url) || $url === '') {
            return '';
        }

        $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_TERM, $termId, $taxonomy, $url, 1);
        $meta = $this->metaForContextCached($ctx);

        return is_string($meta->title) ? (string) $meta->title : '';
    }

    private function getSeoDescriptionForTerm(int $termId, string $taxonomy): string
    {
        $url = get_term_link($termId, $taxonomy);
        if (is_wp_error($url) || !is_string($url) || $url === '') {
            return '';
        }

        $ctx = new SeoDocumentContext(SeoDocumentContext::TYPE_TERM, $termId, $taxonomy, $url, 1);
        $meta = $this->metaForContextCached($ctx);

        return is_string($meta->description) ? (string) $meta->description : '';
    }

    private function normalizeDescriptionKey(string $desc): string
    {
        return AuditMetaSignal::normalizeRenderedDuplicateKey($desc, 191);
    }

    /**
     * Compute a normalized meta-description key used for duplicate detection.
     */
    public function descriptionKeyForPost(int $postId, string $postType): string
    {
        $desc = $this->getSeoDescriptionForPost($postId, $postType);
        return $this->normalizeDescriptionKey($desc);
    }

    /**
     * Compute a normalized meta-description key for a term archive.
     */
    public function descriptionKeyForTerm(int $termId, string $taxonomy): string
    {
        $desc = $this->getSeoDescriptionForTerm($termId, $taxonomy);
        return $this->normalizeDescriptionKey($desc);
    }

    private function normalizeTitleKey(string $title): string
    {
        return AuditMetaSignal::normalizeRenderedDuplicateKey($title, 191);
    }

    /**
     * Compute a normalized SEO-title key used for duplicate detection.
     */
    public function titleKeyForPost(int $postId, string $postType): string
    {
        $seoTitle = $this->getSeoTitleForPost($postId, $postType);
        return $this->normalizeTitleKey($seoTitle);
    }

    /**
     * Compute a normalized SEO-title key for a term archive.
     */
    public function titleKeyForTerm(int $termId, string $taxonomy): string
    {
        $seoTitle = $this->getSeoTitleForTerm($termId, $taxonomy);
        return $this->normalizeTitleKey($seoTitle);
    }

    /**
     * Apply a frozen scan config (used by async jobs to keep consistency).
     *
     * @param array<string, mixed> $config
     */
    public function applyScanConfig(array $config): void
    {
        $this->newsSitemapEnabled = !empty($config['news_sitemap_enabled']);
        $this->newsPostTypes = isset($config['news_post_types']) && is_array($config['news_post_types'])
            ? array_values(array_filter(array_map('sanitize_key', $config['news_post_types'])))
            : [];
        $this->newsSinceTs = isset($config['news_since_ts']) ? (int) $config['news_since_ts'] : 0;
        $this->newsEligible = 0;

        $this->videoSitemapEnabled = !empty($config['video_sitemap_enabled']);
        $this->videoMode = isset($config['video_mode']) ? sanitize_key((string) $config['video_mode']) : 'none';
        $this->videoIncludeFeaturedThumbnail = !empty($config['video_include_featured_thumbnail']);
        $this->videoCandidates = 0;
        $this->videoEligible = 0;
    }

    public function getNewsEligible(): int
    {
        return (int) $this->newsEligible;
    }

    public function getVideoCandidates(): int
    {
        return (int) $this->videoCandidates;
    }

    public function getVideoEligible(): int
    {
        return (int) $this->videoEligible;
    }
}
