<?php

/**
 * Complete Internal Linking REST API Endpoints
 * 
 * All endpoints needed for the internal linking system to work with real data
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\RestApi;

use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use ProRank\SEO\Plugin;
use ProRank\SEO\Core\FocusKeywords;
use ProRank\SEO\Core\LicenseManager;
use ProRank\SEO\Modules\Content\InternalLinking\BuilderContentSupport;
use ProRank\SEO\Modules\Content\InternalLinking\EnhancedLinkSuggestionService;
use ProRank\SEO\Modules\Content\InternalLinking\OperationsManager;
use ProRank\SEO\Modules\Content\InternalLinking\SettingsStore;
use ProRank\SEO\Modules\Content\InternalLinking\TableRegistry;

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

const LINKING_SCAN_HISTORY_OPTION = 'prorank_internal_linking_scan_history';
const LINKING_SCAN_HISTORY_LIMIT = 30;
const LINKING_SCAN_METHOD_VERSION = '2';
const LINKING_FULL_REPORT_OPTION = 'prorank_internal_linking_full_report';
const AUTO_LINK_BATCH_PROGRESS_OPTION = 'prorank_auto_link_batch_progress';
const AUTO_LINK_BATCH_HOOK = 'prorank_auto_link_batch_process';
const AUTO_LINK_BATCH_LOCK_TRANSIENT = 'prorank_auto_link_batch_lock';
const AUTO_LINK_BATCH_SIZE = 25;
const AUTO_LINK_PREVIEW_LIMIT = 100;

add_action(AUTO_LINK_BATCH_HOOK, __NAMESPACE__ . '\process_auto_link_batch_event', 10, 1);


/**
 * Get internal linking settings (merge legacy + current + premium keys).
 *
 * @return array
 */


function get_internal_linking_settings(): array {
    return SettingsStore::migrate();
}

/**
 * Get normalized internal linking runtime settings with defaults.
 *
 * @return array
 */
function get_internal_linking_runtime_settings(): array {
    $defaults = [
        'enabled' => true,
        'auto_linking' => false,
        'enable_link_analysis' => true,
        'track_ctr' => true,
        'generate_reports' => true,
        'detect_broken_links' => true,
        'check_frequency' => 'weekly',
        'email_broken_links' => false,
        'enable_link_map' => true,
        'show_orphan_pages' => true,
        'show_link_strength' => true,
        'post_types' => ['post', 'page'],
        'link_post_types' => ['post', 'page'],
        'post_statuses' => 'publish',
        'max_post_age' => 'no_limit',
        'track_clicks' => false,
        'enable_click_tracking' => false,
        'skip_sentences_with_links' => true,
        'max_links_per_post' => 5,
        'max_outbound_links' => 0,
    ];

    $settings = wp_parse_args(get_internal_linking_settings(), $defaults);
    $click_tracking_enabled = isset($settings['enable_click_tracking'])
        ? (bool) $settings['enable_click_tracking']
        : (bool) ($settings['track_clicks'] ?? false);
    $settings['track_clicks'] = $click_tracking_enabled;
    $settings['enable_click_tracking'] = $click_tracking_enabled;

    return $settings;
}

/**
 * Normalize configured post types from settings.
 *
 * @param array $settings Internal linking settings.
 * @return array
 */
function get_internal_linking_scoped_post_types(array $settings): array {
    $raw_post_types = $settings['post_types'] ?? ($settings['link_post_types'] ?? ['post', 'page']);
    if (!is_array($raw_post_types)) {
        $raw_post_types = (array) $raw_post_types;
    }

    $post_types = array_values(array_unique(array_filter(array_map('sanitize_key', $raw_post_types))));
    if (empty($post_types)) {
        $post_types = ['post', 'page'];
    }

    return $post_types;
}

/**
 * Normalize configured post statuses from settings.
 *
 * @param array $settings Internal linking settings.
 * @return array
 */
function get_internal_linking_scoped_post_statuses(array $settings): array {
    $raw_statuses = $settings['post_statuses'] ?? 'publish';
    if (is_array($raw_statuses)) {
        $statuses = $raw_statuses;
    } else {
        $statuses = array_map('trim', explode(',', (string) $raw_statuses));
    }

    $statuses = array_values(array_unique(array_filter(array_map('sanitize_key', $statuses))));
    if (empty($statuses)) {
        $statuses = ['publish'];
    }

    return $statuses;
}

/**
 * Convert max age setting into integer day limit.
 *
 * @param array $settings Internal linking settings.
 * @return int|null
 */
function get_internal_linking_max_age_days(array $settings): ?int {
    $raw_value = trim((string) ($settings['max_post_age'] ?? 'no_limit'));
    if ($raw_value === '' || $raw_value === 'no_limit') {
        return null;
    }

    if (preg_match('/^(\d+)\s*(day|days)$/i', $raw_value, $matches)) {
        return max(1, (int) $matches[1]);
    }

    if (preg_match('/^(\d+)\s*(month|months)$/i', $raw_value, $matches)) {
        return max(1, (int) $matches[1]) * 30;
    }

    if (preg_match('/^(\d+)\s*(year|years)$/i', $raw_value, $matches)) {
        return max(1, (int) $matches[1]) * 365;
    }

    return null;
}

/**
 * Get builder-aware content for internal-linking analysis.
 *
 * @param int         $post_id  Post ID.
 * @param string|null $fallback Optional fallback content.
 * @return string
 */
function get_post_linking_content(int $post_id, ?string $fallback = ''): string {
    $post = get_post($post_id);
    if ($post instanceof \WP_Post) {
        return BuilderContentSupport::get_combined_content($post_id, $post);
    }

    return is_string($fallback) ? $fallback : '';
}

/**
 * Normalize permalink path for internal lookup.
 *
 * @param string|null $path URL path.
 * @return string
 */
function normalize_internal_link_path(?string $path): string {
    $normalized = rawurldecode((string) $path);
    $normalized = trim($normalized);

    if ($normalized === '') {
        return '/';
    }

    if ($normalized[0] !== '/') {
        $normalized = '/' . $normalized;
    }

    if (str_starts_with($normalized, '/index.php/')) {
        $normalized = substr($normalized, 10);
    }

    if ($normalized !== '/') {
        $normalized = rtrim($normalized, '/');
    }

    return $normalized === '' ? '/' : $normalized;
}

/**
 * Resolve internal URL to post ID using WP resolver with path-map fallback.
 *
 * @param string $url      Absolute internal URL.
 * @param array  $path_map Normalized path => post ID map.
 * @return int
 */
function resolve_internal_post_id(string $url, array $path_map = []): int {
    $target_id = (int) url_to_postid($url);
    if ($target_id > 0) {
        return $target_id;
    }

    $query = (string) wp_parse_url($url, PHP_URL_QUERY);
    if ($query !== '') {
        parse_str($query, $query_args);
        if (!empty($query_args['p']) && is_numeric($query_args['p'])) {
            return (int) $query_args['p'];
        }
        if (!empty($query_args['page_id']) && is_numeric($query_args['page_id'])) {
            return (int) $query_args['page_id'];
        }
    }

    if (!empty($path_map)) {
        $normalized_path = normalize_internal_link_path((string) wp_parse_url($url, PHP_URL_PATH));
        if (isset($path_map[$normalized_path])) {
            return (int) $path_map[$normalized_path];
        }
    }

    return 0;
}

/**
 * Normalize a URL candidate from content analysis.
 *
 * @param string $url Raw URL candidate.
 * @return string
 */
function normalize_linking_url(string $url): string {
    $url = trim(html_entity_decode($url));
    if (
        $url === '' ||
        str_starts_with($url, '#') ||
        str_starts_with($url, 'mailto:') ||
        str_starts_with($url, 'tel:') ||
        str_starts_with($url, 'javascript:') ||
        str_starts_with($url, 'data:')
    ) {
        return '';
    }

    if (str_starts_with($url, '//')) {
        $preferred_scheme = (string) wp_parse_url(home_url('/'), PHP_URL_SCHEME);
        if ($preferred_scheme === '') {
            $preferred_scheme = is_ssl() ? 'https' : 'http';
        }
        $url = $preferred_scheme . ':' . $url;
    } elseif (str_starts_with($url, '/')) {
        $url = home_url($url);
    }

    return $url;
}

/**
 * Normalize host values for same-site comparisons.
 *
 * @param string $host Hostname to normalize.
 * @return string
 */
function normalize_linking_host_for_compare(string $host): string {
    $host = strtolower(trim($host));
    if (str_starts_with($host, 'www.')) {
        $host = substr($host, 4);
    }

    return $host;
}

/**
 * Determine whether a normalized URL is internal to the current site host.
 *
 * @param string $url       Normalized URL.
 * @param string $site_host Current site host.
 * @return bool
 */
function is_internal_linking_url(string $url, string $site_host): bool {
    $url_host = (string) wp_parse_url($url, PHP_URL_HOST);
    return $url_host === '' || normalize_linking_host_for_compare($url_host) === normalize_linking_host_for_compare($site_host);
}

/**
 * Rewrite a same-site absolute URL to the site's configured scheme/host.
 *
 * @param string $url Absolute or relative URL.
 * @return string
 */
function canonicalize_internal_linking_url(string $url): string {
    $url_host = (string) wp_parse_url($url, PHP_URL_HOST);
    if ($url_host === '') {
        return $url;
    }

    $home_scheme = (string) wp_parse_url(home_url('/'), PHP_URL_SCHEME);
    $home_host = (string) wp_parse_url(home_url('/'), PHP_URL_HOST);
    if ($home_scheme === '' || $home_host === '') {
        return $url;
    }

    $path = (string) wp_parse_url($url, PHP_URL_PATH);
    $query = (string) wp_parse_url($url, PHP_URL_QUERY);
    $fragment = (string) wp_parse_url($url, PHP_URL_FRAGMENT);
    $port = wp_parse_url(home_url('/'), PHP_URL_PORT);

    $canonical = $home_scheme . '://' . $home_host;
    if ($port !== null && $port !== false && $port !== '') {
        $canonical .= ':' . $port;
    }
    $canonical .= $path;

    if ($query !== '') {
        $canonical .= '?' . $query;
    }

    if ($fragment !== '') {
        $canonical .= '#' . $fragment;
    }

    return $canonical;
}

/**
 * Determine whether a URL points to content we should analyze for internal linking.
 *
 * Excludes common static/media/admin/resource URLs that are not content destinations.
 *
 * @param string $url Normalized URL.
 * @return bool
 */
function is_linking_content_url(string $url): bool {
    $path = (string) wp_parse_url($url, PHP_URL_PATH);
    $path = trim($path);

    if ($path === '' || $path === '/') {
        return true;
    }

    $lower_path = strtolower($path);
    $excluded_prefixes = [
        '/wp-content/',
        '/wp-includes/',
        '/wp-admin/',
        '/wp-json/',
    ];
    foreach ($excluded_prefixes as $prefix) {
        if (str_starts_with($lower_path, $prefix)) {
            return false;
        }
    }

    $extension = strtolower((string) pathinfo($lower_path, PATHINFO_EXTENSION));
    if ($extension === '') {
        return true;
    }

    $excluded_extensions = [
        'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp', 'tif', 'tiff',
        'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar', '7z',
        'mp3', 'wav', 'ogg', 'm4a', 'mp4', 'mov', 'avi', 'webm', 'mkv',
        'css', 'js', 'json', 'xml', 'txt', 'csv',
        'woff', 'woff2', 'ttf', 'eot', 'otf',
    ];

    return !in_array($extension, $excluded_extensions, true);
}

/**
 * Extract all links for a post using builder-aware parsing with HTML fallback.
 *
 * @param int         $post_id  Post ID.
 * @param string|null $fallback Optional fallback HTML content.
 * @return array<int,string>
 */
function get_post_linking_urls(int $post_id, ?string $fallback = ''): array {
    $post = get_post($post_id);
    $urls = [];

    if ($post instanceof \WP_Post) {
        $urls = BuilderContentSupport::extract_all_links($post_id, $post);
    }

    if (!empty($urls)) {
        $normalized_urls = [];
        foreach ($urls as $raw_url) {
            $normalized = normalize_linking_url((string) $raw_url);
            if ($normalized === '' || !is_linking_content_url($normalized)) {
                continue;
            }
            $normalized_urls[] = $normalized;
        }

        return array_values(array_unique($normalized_urls));
    }

    $content = is_string($fallback) ? $fallback : '';
    if ($content === '') {
        return [];
    }

    preg_match_all('/<a[^>]+href=[\'"]([^\'"]+)[\'"][^>]*>/i', $content, $matches);
    $fallback_urls = [];
    foreach (($matches[1] ?? []) as $raw_url) {
        $normalized = normalize_linking_url((string) $raw_url);
        if ($normalized !== '' && is_linking_content_url($normalized)) {
            $fallback_urls[] = $normalized;
        }
    }

    return array_values(array_unique($fallback_urls));
}

/**
 * Normalize and sanitize a stored scan snapshot.
 *
 * @param array $snapshot Raw snapshot array.
 * @return array<string,mixed>|null
 */
function normalize_internal_linking_scan_snapshot(array $snapshot): ?array {
    $scanned_at = (string) ($snapshot['scanned_at'] ?? '');
    if ($scanned_at === '') {
        return null;
    }

    $method_version = (string) ($snapshot['method_version'] ?? 'legacy');
    if ($method_version === '') {
        $method_version = 'legacy';
    }

    $total_posts = max(0, (int) ($snapshot['total_posts'] ?? 0));
    $posts_with_links = max(0, (int) ($snapshot['posts_with_links'] ?? 0));
    $link_coverage = round((float) ($snapshot['link_coverage'] ?? 0), 2);

    if ($posts_with_links === 0 && $total_posts > 0 && $link_coverage > 0) {
        $posts_with_links = min(
            $total_posts,
            max(0, (int) round(($link_coverage / 100) * $total_posts))
        );
    }

    return [
        'scanned_at' => $scanned_at,
        'method_version' => sanitize_text_field($method_version),
        'total_posts' => $total_posts,
        'total_internal_links' => max(0, (int) ($snapshot['total_internal_links'] ?? 0)),
        'total_external_links' => max(0, (int) ($snapshot['total_external_links'] ?? 0)),
        'posts_with_links' => $posts_with_links,
        'unique_internal_targets' => max(0, (int) ($snapshot['unique_internal_targets'] ?? 0)),
        'orphan_count' => max(0, (int) ($snapshot['orphan_count'] ?? 0)),
        'broken_links' => max(0, (int) ($snapshot['broken_links'] ?? 0)),
        'link_coverage' => $link_coverage,
        'avg_links' => round((float) ($snapshot['avg_links'] ?? 0), 2),
        'external_percentage' => round((float) ($snapshot['external_percentage'] ?? 0), 2),
    ];
}

/**
 * Build a stable signature for scan-history deduplication.
 *
 * @param array<string,mixed> $snapshot Normalized snapshot.
 * @return string
 */
function build_internal_linking_scan_snapshot_signature(array $snapshot): string {
    return wp_json_encode([
        'total_posts' => (int) ($snapshot['total_posts'] ?? 0),
        'total_internal_links' => (int) ($snapshot['total_internal_links'] ?? 0),
        'total_external_links' => (int) ($snapshot['total_external_links'] ?? 0),
        'orphan_count' => (int) ($snapshot['orphan_count'] ?? 0),
        'broken_links' => (int) ($snapshot['broken_links'] ?? 0),
        'link_coverage' => round((float) ($snapshot['link_coverage'] ?? 0), 2),
        'avg_links' => round((float) ($snapshot['avg_links'] ?? 0), 2),
        'external_percentage' => round((float) ($snapshot['external_percentage'] ?? 0), 2),
    ]) ?: '';
}

/**
 * Score a snapshot for history deduplication preference.
 *
 * @param array<string,mixed> $snapshot Normalized snapshot.
 * @return int
 */
function get_internal_linking_scan_snapshot_quality_score(array $snapshot): int {
    $score = 0;
    $method_version = (string) ($snapshot['method_version'] ?? 'legacy');

    if ($method_version === LINKING_SCAN_METHOD_VERSION) {
        $score += 20;
    } elseif ($method_version !== 'legacy') {
        $score += 10;
    }

    if ((int) ($snapshot['posts_with_links'] ?? 0) > 0) {
        $score += 5;
    }

    if ((int) ($snapshot['unique_internal_targets'] ?? 0) > 0) {
        $score += 5;
    }

    return $score;
}

/**
 * Decide whether a normalized snapshot should replace the current duplicate.
 *
 * @param array<string,mixed> $candidate Candidate snapshot.
 * @param array<string,mixed> $current   Current retained snapshot.
 * @return bool
 */
function should_prefer_internal_linking_scan_snapshot(array $candidate, array $current): bool {
    $candidate_quality = get_internal_linking_scan_snapshot_quality_score($candidate);
    $current_quality = get_internal_linking_scan_snapshot_quality_score($current);

    if ($candidate_quality !== $current_quality) {
        return $candidate_quality > $current_quality;
    }

    return strcmp((string) ($candidate['scanned_at'] ?? ''), (string) ($current['scanned_at'] ?? '')) > 0;
}

/**
 * Normalize, sort, and deduplicate scan history.
 *
 * @param array<int,mixed> $history Raw or normalized history rows.
 * @return array<int,array<string,mixed>>
 */
function cleanup_internal_linking_scan_history(array $history): array {
    $normalized_history = [];
    foreach ($history as $entry) {
        if (!is_array($entry)) {
            continue;
        }

        $normalized = normalize_internal_linking_scan_snapshot($entry);
        if ($normalized !== null) {
            $normalized_history[] = $normalized;
        }
    }

    usort($normalized_history, static function (array $a, array $b): int {
        return strcmp((string) ($b['scanned_at'] ?? ''), (string) ($a['scanned_at'] ?? ''));
    });

    $deduped = [];
    foreach ($normalized_history as $snapshot) {
        $signature = build_internal_linking_scan_snapshot_signature($snapshot);
        if ($signature === '') {
            $deduped[] = $snapshot;
            continue;
        }

        if (!isset($deduped[$signature])) {
            $deduped[$signature] = $snapshot;
            continue;
        }

        if (should_prefer_internal_linking_scan_snapshot($snapshot, $deduped[$signature])) {
            $deduped[$signature] = $snapshot;
        }
    }

    $cleaned = array_values($deduped);
    usort($cleaned, static function (array $a, array $b): int {
        return strcmp((string) ($b['scanned_at'] ?? ''), (string) ($a['scanned_at'] ?? ''));
    });

    if (count($cleaned) > LINKING_SCAN_HISTORY_LIMIT) {
        $cleaned = array_slice($cleaned, 0, LINKING_SCAN_HISTORY_LIMIT);
    }

    return $cleaned;
}

/**
 * Get persisted scan history for internal linking scans.
 *
 * @param int $limit Maximum records to return. Use 0 for no limit.
 * @return array<int,array<string,mixed>>
 */
function get_internal_linking_scan_history(int $limit = 12): array {
    $stored = get_option(LINKING_SCAN_HISTORY_OPTION, []);
    if (!is_array($stored)) {
        return [];
    }

    $history = cleanup_internal_linking_scan_history($stored);

    if ($history !== array_values($stored)) {
        update_option(LINKING_SCAN_HISTORY_OPTION, $history, false);
    }

    if ($limit > 0) {
        $history = array_slice($history, 0, $limit);
    }

    return $history;
}

/**
 * Persist a scan snapshot to history.
 *
 * @param array $snapshot Snapshot payload.
 * @return void
 */
function store_internal_linking_scan_snapshot(array $snapshot): void {
    if (empty($snapshot['method_version'])) {
        $snapshot['method_version'] = LINKING_SCAN_METHOD_VERSION;
    }

    $normalized = normalize_internal_linking_scan_snapshot($snapshot);
    if ($normalized === null) {
        return;
    }

    $history = get_internal_linking_scan_history(0);
    $history[] = $normalized;
    $history = cleanup_internal_linking_scan_history($history);

    update_option(LINKING_SCAN_HISTORY_OPTION, $history, false);
}

/**
 * Get the cached full links report generated by the last completed scan.
 *
 * @return array<string,mixed>
 */
function get_internal_linking_full_report_cache(): array {
    $stored = get_option(LINKING_FULL_REPORT_OPTION, []);
    if (!is_array($stored) || empty($stored['generated_at'])) {
        return [];
    }

    $posts = [];
    foreach ((array) ($stored['posts'] ?? []) as $row) {
        if (!is_array($row)) {
            continue;
        }

        $posts[] = [
            'id' => (int) ($row['id'] ?? 0),
            'title' => (string) ($row['title'] ?? ''),
            'url' => (string) ($row['url'] ?? ''),
            'post_type' => (string) ($row['post_type'] ?? ''),
            'date' => (string) ($row['date'] ?? ''),
            'categories' => array_values(array_map('intval', array_filter((array) ($row['categories'] ?? []), 'is_numeric'))),
            'inbound_internal' => (int) ($row['inbound_internal'] ?? 0),
            'outbound_internal' => (int) ($row['outbound_internal'] ?? 0),
            'outbound_external' => (int) ($row['outbound_external'] ?? 0),
            'broken_links' => (int) ($row['broken_links'] ?? 0),
        ];
    }

    $stored_stats = is_array($stored['stats'] ?? null) ? $stored['stats'] : [];

    return [
        'generated_at' => (string) $stored['generated_at'],
        'last_scan' => !empty($stored['last_scan']) ? (string) $stored['last_scan'] : null,
        'posts' => $posts,
        'stats' => [
            'total_posts' => (int) ($stored_stats['total_posts'] ?? count($posts)),
            'total_internal' => (int) ($stored_stats['total_internal'] ?? 0),
            'total_external' => (int) ($stored_stats['total_external'] ?? 0),
            'orphan_count' => (int) ($stored_stats['orphan_count'] ?? 0),
            'broken_count' => (int) ($stored_stats['broken_count'] ?? 0),
        ],
    ];
}

/**
 * Persist the cached full links report from the latest completed scan.
 *
 * @param array<string,mixed> $payload Full report payload.
 * @return void
 */
function store_internal_linking_full_report_cache(array $payload): void {
    $posts = [];
    foreach ((array) ($payload['posts'] ?? []) as $row) {
        if (!is_array($row)) {
            continue;
        }

        $posts[] = [
            'id' => (int) ($row['id'] ?? 0),
            'title' => (string) ($row['title'] ?? ''),
            'url' => (string) ($row['url'] ?? ''),
            'post_type' => (string) ($row['post_type'] ?? ''),
            'date' => (string) ($row['date'] ?? ''),
            'categories' => array_values(array_map('intval', array_filter((array) ($row['categories'] ?? []), 'is_numeric'))),
            'inbound_internal' => (int) ($row['inbound_internal'] ?? 0),
            'outbound_internal' => (int) ($row['outbound_internal'] ?? 0),
            'outbound_external' => (int) ($row['outbound_external'] ?? 0),
            'broken_links' => (int) ($row['broken_links'] ?? 0),
        ];
    }

    $stats = is_array($payload['stats'] ?? null) ? $payload['stats'] : [];

    update_option(LINKING_FULL_REPORT_OPTION, [
        'generated_at' => !empty($payload['generated_at']) ? (string) $payload['generated_at'] : current_time('mysql'),
        'last_scan' => !empty($payload['last_scan']) ? (string) $payload['last_scan'] : null,
        'posts' => $posts,
        'stats' => [
            'total_posts' => (int) ($stats['total_posts'] ?? count($posts)),
            'total_internal' => (int) ($stats['total_internal'] ?? 0),
            'total_external' => (int) ($stats['total_external'] ?? 0),
            'orphan_count' => (int) ($stats['orphan_count'] ?? 0),
            'broken_count' => (int) ($stats['broken_count'] ?? 0),
        ],
    ], false);
}

/**
 * Build summary stats for the links report from the latest scan snapshot.
 *
 * @param array<string,mixed>|null $snapshot Latest snapshot.
 * @return array<string,int>
 */
function build_internal_linking_full_report_stats_from_snapshot(?array $snapshot): array {
    if (!is_array($snapshot)) {
        return [
            'total_posts' => 0,
            'total_internal' => 0,
            'total_external' => 0,
            'orphan_count' => 0,
            'broken_count' => 0,
        ];
    }

    return [
        'total_posts' => (int) ($snapshot['total_posts'] ?? 0),
        'total_internal' => (int) ($snapshot['total_internal_links'] ?? 0),
        'total_external' => (int) ($snapshot['total_external_links'] ?? 0),
        'orphan_count' => (int) ($snapshot['orphan_count'] ?? 0),
        'broken_count' => (int) ($snapshot['broken_links'] ?? 0),
    ];
}

/**
 * Build trend payload from history.
 *
 * @param array<int,array<string,mixed>> $history Scan history (newest first).
 * @return array<string,mixed>
 */
function build_internal_linking_scan_trends(array $history): array {
    $latest = $history[0] ?? null;
    $previous = $history[1] ?? null;

    $deltas = [
        'total_internal_links' => 0,
        'orphan_count' => 0,
        'broken_links' => 0,
        'link_coverage' => 0.0,
        'avg_links' => 0.0,
    ];

    $has_compatible_previous = is_array($latest) && is_array($previous)
        && (string) ($latest['method_version'] ?? 'legacy') === (string) ($previous['method_version'] ?? 'legacy');

    if ($has_compatible_previous) {
        $deltas['total_internal_links'] = (int) ($latest['total_internal_links'] ?? 0) - (int) ($previous['total_internal_links'] ?? 0);
        $deltas['orphan_count'] = (int) ($latest['orphan_count'] ?? 0) - (int) ($previous['orphan_count'] ?? 0);
        $deltas['broken_links'] = (int) ($latest['broken_links'] ?? 0) - (int) ($previous['broken_links'] ?? 0);
        $deltas['link_coverage'] = round((float) ($latest['link_coverage'] ?? 0) - (float) ($previous['link_coverage'] ?? 0), 2);
        $deltas['avg_links'] = round((float) ($latest['avg_links'] ?? 0) - (float) ($previous['avg_links'] ?? 0), 2);
    }

    return [
        'has_previous' => $has_compatible_previous,
        'latest' => $latest,
        'previous' => $has_compatible_previous ? $previous : null,
        'deltas' => $deltas,
    ];
}

/**
 * Build human-readable "what changed" payload from scan trends.
 *
 * @param array<string,mixed> $scan_trends Trend payload.
 * @return array<int,array<string,mixed>>
 */
function build_internal_linking_recent_changes(array $scan_trends): array {
    if (empty($scan_trends['has_previous'])) {
        return [];
    }

    $deltas = (array) ($scan_trends['deltas'] ?? []);
    $latest = (array) ($scan_trends['latest'] ?? []);
    $previous = (array) ($scan_trends['previous'] ?? []);

    $metrics = [
        'total_internal_links' => [
            'label' => __('Internal Links', 'prorank-seo'),
            'precision' => 0,
            'inverse' => false,
            'unit' => '',
        ],
        'orphan_count' => [
            'label' => __('Orphan Pages', 'prorank-seo'),
            'precision' => 0,
            'inverse' => true,
            'unit' => '',
        ],
        'broken_links' => [
            'label' => __('Broken Links', 'prorank-seo'),
            'precision' => 0,
            'inverse' => true,
            'unit' => '',
        ],
        'link_coverage' => [
            'label' => __('Link Coverage', 'prorank-seo'),
            'precision' => 2,
            'inverse' => false,
            'unit' => 'pp',
        ],
        'avg_links' => [
            'label' => __('Avg Links / Content', 'prorank-seo'),
            'precision' => 2,
            'inverse' => false,
            'unit' => '',
        ],
    ];

    $changes = [];
    foreach ($metrics as $metric => $meta) {
        $raw_delta = (float) ($deltas[$metric] ?? 0);
        $abs_delta = abs($raw_delta);

        $status = 'flat';
        if ($abs_delta > 0.0001) {
            $improved = ($raw_delta > 0 && empty($meta['inverse'])) || ($raw_delta < 0 && !empty($meta['inverse']));
            $status = $improved ? 'improved' : 'declined';
        }

        $changes[] = [
            'metric' => $metric,
            'label' => $meta['label'],
            'delta' => round($raw_delta, (int) $meta['precision']),
            'unit' => (string) ($meta['unit'] ?? ''),
            'status' => $status,
            'current' => $latest[$metric] ?? 0,
            'previous' => $previous[$metric] ?? 0,
        ];
    }

    return $changes;
}

/**
 * Ensure internal-linking tables are ready and return current readiness state.
 *
 * @param bool $repair Whether to force schema repair.
 * @return array<string, mixed>
 */
function ensure_internal_linking_schema_ready(bool $repair = false): array {
    if ($repair) {
        return TableRegistry::ensure_tables(true, true);
    }

    $report = TableRegistry::get_readiness_report(true, false);
    if (!$report['ready'] || $report['version_mismatch']) {
        return TableRegistry::ensure_tables(true, false);
    }

    return $report;
}

/**
 * Register all internal linking endpoints
 */
function register_internal_linking_endpoints() {
    $namespace = 'prorank-seo/v1';
    
    // Debug logging
    prorank_log('[ProRank] Registering internal linking endpoints at: ' . current_action());
    
    // Dashboard endpoint
    register_rest_route($namespace, '/linking/dashboard', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_dashboard_data',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Orphaned Posts Endpoints
    register_rest_route($namespace, '/linking/orphaned-posts', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_orphaned_posts',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Also register the endpoint the frontend is expecting
    register_rest_route($namespace, '/linking/orphans', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_orphaned_posts_new',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Inbound opportunities endpoint
    register_rest_route($namespace, '/linking/inbound-opportunities', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_inbound_opportunities',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Scan opportunities endpoint
    register_rest_route($namespace, '/linking/scan-opportunities', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\scan_inbound_opportunities',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/scan-orphans', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\scan_orphaned_posts',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/suggestions-for-orphan/(?P<id>\d+)', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_suggestions_for_orphan',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/create-links-batch', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\create_links_batch',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);

    register_rest_route($namespace, '/linking/apply-link', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\apply_link',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Fix Broken Links Endpoints
    register_rest_route($namespace, '/linking/fix-broken-link', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\fix_broken_link',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/fix-broken-batch', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\fix_broken_links_batch',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/ignore-broken-link', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\ignore_broken_link',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Auto-Linking Endpoints
    register_rest_route($namespace, '/linking/auto-link-rules', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_auto_link_rules',
        'permission_callback' => function() { return current_user_can('manage_options'); },
    ]);
    
    register_rest_route($namespace, '/linking/auto-link-rule', [
        [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => __NAMESPACE__ . '\create_auto_link_rule',
            'permission_callback' => function() { return current_user_can('manage_options'); },
        ],
        [
            'methods' => WP_REST_Server::EDITABLE,
            'callback' => __NAMESPACE__ . '\update_auto_link_rule',
            'permission_callback' => function() { return current_user_can('manage_options'); },
        ],
    ]);
    
    register_rest_route($namespace, '/linking/auto-link-rule/(?P<id>[A-Za-z0-9_-]+)', [
        'methods' => WP_REST_Server::DELETABLE,
        'callback' => __NAMESPACE__ . '\delete_auto_link_rule',
        'permission_callback' => function() { return current_user_can('manage_options'); },
    ]);
    
    register_rest_route($namespace, '/linking/auto-link-rule/(?P<id>[A-Za-z0-9_-]+)/toggle', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\toggle_auto_link_rule',
        'permission_callback' => function() { return current_user_can('manage_options'); },
    ]);
    
    register_rest_route($namespace, '/linking/run-auto-linking', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\run_auto_linking',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);

    register_rest_route($namespace, '/linking/cancel-auto-linking', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\cancel_auto_linking',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);

    register_rest_route($namespace, '/linking/resume-auto-linking', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\resume_auto_linking',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);

    register_rest_route($namespace, '/linking/clear-auto-linking-job', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\clear_auto_linking_job',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/auto-link-stats', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_auto_link_stats',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/generate-gsc-rules', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\generate_gsc_auto_link_rules',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Analysis Endpoints (analysis endpoint is in LinkAnalysisEndpoint.php)
    register_rest_route($namespace, '/linking/analyze', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\run_link_analysis',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/analysis-report', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_analysis_report',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/full-report', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_full_report',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/generate-full-report', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\generate_full_report',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/export-report', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\export_report',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Keyword Recommendations Endpoint
    register_rest_route($namespace, '/linking/keywords/(?P<id>\d+)', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_post_keywords',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Scan Info
    register_rest_route($namespace, '/linking/scan-info', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_scan_info',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);

    register_rest_route($namespace, '/linking/operations-state', [
        [
            'methods' => WP_REST_Server::READABLE,
            'callback' => __NAMESPACE__ . '\get_linking_operations_state',
            'permission_callback' => function() { return current_user_can('manage_options'); },
        ],
        [
            'methods' => WP_REST_Server::CREATABLE,
            'callback' => __NAMESPACE__ . '\update_linking_operations_state',
            'permission_callback' => function() { return current_user_can('manage_options'); },
        ],
    ]);

    register_rest_route($namespace, '/linking/operations-run', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\run_linking_operations_cycle',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);

    register_rest_route($namespace, '/linking/table-readiness', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_table_readiness',
        'permission_callback' => function() { return current_user_can('manage_options'); },
    ]);
    
    register_rest_route($namespace, '/linking/start-scan', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\start_full_scan',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    // Broken Links Endpoints
    register_rest_route($namespace, '/linking/broken-links', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_broken_links',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/scan-broken-links', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\scan_broken_links',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/scan-progress/(?P<scan_id>[a-zA-Z0-9_-]+)', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_scan_progress',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);

    // General scan progress endpoint (without scan_id)
    register_rest_route($namespace, '/linking/scan-progress', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_general_scan_progress',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);

    register_rest_route($namespace, '/linking/ignore-link/(?P<id>\d+)', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\ignore_broken_link',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);
    
    register_rest_route($namespace, '/linking/delete-broken-links', [
        'methods' => WP_REST_Server::DELETABLE,
        'callback' => __NAMESPACE__ . '\delete_broken_links',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ]);

    // Visual Link Map Endpoints
    register_rest_route($namespace, '/linkmap/data', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_linkmap_visualization_data',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ], true);

    register_rest_route($namespace, '/linkmap/node/(?P<id>\d+)', [
        'methods' => WP_REST_Server::READABLE,
        'callback' => __NAMESPACE__ . '\get_linkmap_node_details',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ], true);

    register_rest_route($namespace, '/linkmap/export', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => __NAMESPACE__ . '\export_linkmap_data',
        'permission_callback' => function() { return current_user_can('edit_posts'); },
    ], true);
    
}

// Registration is handled by prorank-seo.php
// Commenting out auto-registration to prevent conflicts
// if (did_action('rest_api_init')) {
//     register_internal_linking_endpoints();
// } else {
//     add_action('rest_api_init', __NAMESPACE__ . '\register_internal_linking_endpoints');
// }

/**
 * Get orphaned posts (posts with no internal links pointing to them)
 * Optimized with pagination to prevent memory exhaustion on large sites
 */
function get_orphaned_posts(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;

    $post_type = $request->get_param('post_type') ?: 'all';
    $sort = $request->get_param('sort') ?: 'date';
    $page = max(1, (int) $request->get_param('page') ?: 1);
    $per_page = min(100, max(10, (int) $request->get_param('per_page') ?: 50));
    $offset = ($page - 1) * $per_page;

    // Build post type condition
    $post_types = $post_type === 'all' ? ['post', 'page'] : (array) $post_type;
    $post_types = array_values(array_filter(array_map('sanitize_key', $post_types)));
    if (empty($post_types)) {
        $post_types = ['post', 'page'];
    }

    if (count($post_types) === 1) {
        // Get total count first for pagination info
        $total_posts = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->posts}
                WHERE post_status = 'publish'
                AND post_type = %s",
                $post_types[0]
            )
        );

        // Get paginated published posts
        $all_posts = $wpdb->get_results($wpdb->prepare(
            "SELECT ID, post_title, post_name, post_date, post_type, post_content
            FROM {$wpdb->posts}
            WHERE post_status = 'publish'
            AND post_type = %s
            ORDER BY post_date DESC
            LIMIT %d OFFSET %d",
            $post_types[0],
            $per_page,
            $offset
        ));
    } else {
        $post_type_args = array_slice($post_types, 0, 2);

        // Get total count first for pagination info
        $total_posts = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->posts}
                WHERE post_status = 'publish'
                AND post_type IN (%s, %s)",
                $post_type_args[0],
                $post_type_args[1]
            )
        );

        // Get paginated published posts
        $all_posts = $wpdb->get_results($wpdb->prepare(
            "SELECT ID, post_title, post_name, post_date, post_type, post_content
            FROM {$wpdb->posts}
            WHERE post_status = 'publish'
            AND post_type IN (%s, %s)
            ORDER BY post_date DESC
            LIMIT %d OFFSET %d",
            $post_type_args[0],
            $post_type_args[1],
            $per_page,
            $offset
        ));
    }
    
    $orphaned = [];
    $page_post_count = count($all_posts);
    
    foreach ($all_posts as $post) {
        $permalink = get_permalink($post->ID);
        
        // Check if any other post links to this one
        $has_inbound = $wpdb->get_var($wpdb->prepare(
            "SELECT 1 FROM {$wpdb->posts} 
            WHERE post_status = 'publish' 
            AND ID != %d 
            AND post_content LIKE %s 
            LIMIT 1",
            $post->ID,
            '%' . $wpdb->esc_like($permalink) . '%'
        ));
        
        if (!$has_inbound) {
            // Count outbound links
            $outbound_count = substr_count($post->post_content, 'href=');
            $word_count = str_word_count(wp_strip_all_tags($post->post_content));
            
            $orphaned[] = [
                'id' => $post->ID,
                'title' => $post->post_title,
                'slug' => $post->post_name,
                'type' => $post->post_type,
                'date' => $post->post_date,
                'edit_url' => get_edit_post_link($post->ID, 'display'),
                'view_url' => $permalink,
                'word_count' => $word_count,
                'outbound_links' => $outbound_count,
            ];
        }
    }
    
    // Sort results
    if ($sort === 'title') {
        usort($orphaned, function($a, $b) {
            return strcasecmp($a['title'], $b['title']);
        });
    }
    
    // Calculate stats
    $orphaned_count = count($orphaned);
    $percentage = $total_posts > 0 ? round(($orphaned_count / $total_posts) * 100, 1) : 0;
    
    // Save scan date
    update_option('prorank_last_orphan_scan', current_time('mysql'));
    
    $total_pages = ceil($total_posts / $per_page);

    return rest_ensure_response([
        'success' => true,
        'data' => [
            'posts' => $orphaned,
            'stats' => [
                'total_posts' => $total_posts,
                'orphaned_count' => $orphaned_count,
                'percentage' => $percentage,
                'last_scan' => get_option('prorank_last_orphan_scan'),
            ],
            'pagination' => [
                'page' => $page,
                'per_page' => $per_page,
                'total_posts' => $total_posts,
                'total_pages' => $total_pages,
                'has_more' => $page < $total_pages,
            ],
        ],
    ]);
}

/**
 * Scan for orphaned posts
 */
function scan_orphaned_posts(WP_REST_Request $request): WP_REST_Response {
    // This is the same as get_orphaned_posts but with progress tracking
    // For now, we'll use the same implementation
    return get_orphaned_posts($request);
}

/**
 * Get suggestions for an orphaned post
 */
function get_suggestions_for_orphan(WP_REST_Request $request): WP_REST_Response {
    $post_id = (int) $request->get_param('id');
    $post = get_post($post_id);
    
    if (!$post) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Post not found', 'prorank-seo'),
        ]);
    }
    
    // Use the local advanced suggestion service when available.
    if (class_exists(EnhancedLinkSuggestionService::class)) {
        $service = new EnhancedLinkSuggestionService();
        $source_content = get_post_linking_content((int) $post_id, (string) $post->post_content);
        $suggestions = $service->get_suggestions($post_id, $source_content, [
            'limit' => 10,
            'use_ai' => false,
            'use_ai_anchor_generation' => false,
            'use_gsc' => false,
            'skip_cache' => true,
        ]);
    } else {
        // Fallback to basic suggestions
        $suggestions = [];
    }
    
    return rest_ensure_response([
        'success' => true,
        'data' => [
            'suggestions' => $suggestions,
        ],
    ]);
}

/**
 * Create links in batch for orphaned posts
 */
function create_links_batch(WP_REST_Request $request): WP_REST_Response {
    $post_ids = array_values(array_filter(array_map('intval', (array) ($request->get_param('post_ids') ?: []))));
    $source_ids = array_values(array_filter(array_map('intval', (array) ($request->get_param('source_ids') ?: []))));
    $allow_append_fallback = $request->get_param('allow_append_fallback');
    $allow_append_fallback = $allow_append_fallback === null ? true : rest_sanitize_boolean($allow_append_fallback);

    if (empty($post_ids)) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('No orphaned posts were selected.', 'prorank-seo'),
        ]);
    }

    $links_created = 0;
    $fixed_posts = 0;
    $results = [];

    foreach ($post_ids as $post_id) {
        $result = fix_orphan_post_links($post_id, $source_ids, $allow_append_fallback);
        $results[] = $result;

        if (!empty($result['success'])) {
            $fixed_posts++;
            $links_created += (int) ($result['links_created'] ?? 1);
        }
    }

    return rest_ensure_response([
        'success' => true,
        'data' => [
            'links_created' => $links_created,
            'fixed_posts' => $fixed_posts,
            'results' => $results,
        ],
    ]);
}

/**
 * Fix one orphan by inserting a new inbound internal link from the best source page.
 *
 * @param int   $target_post_id         Target orphan ID.
 * @param array $preferred_source_ids   Optional source IDs to prefer.
 * @param bool  $allow_append_fallback  Whether to append a related link block when no inline anchor is found.
 * @return array<string,mixed>
 */
function fix_orphan_post_links(int $target_post_id, array $preferred_source_ids = [], bool $allow_append_fallback = true): array {
    $target = get_post($target_post_id);
    if (!$target instanceof \WP_Post || $target->post_status !== 'publish') {
        return [
            'success' => false,
            'target_id' => $target_post_id,
            'message' => __('Target post was not found.', 'prorank-seo'),
        ];
    }

    $target_url = get_permalink($target);
    if (!is_string($target_url) || $target_url === '') {
        return [
            'success' => false,
            'target_id' => $target_post_id,
            'message' => __('Target URL could not be resolved.', 'prorank-seo'),
        ];
    }

    $anchor_candidates = get_orphan_fix_anchor_candidates($target);
    $source_candidates = get_orphan_fix_source_candidates($target, $preferred_source_ids);

    if (empty($source_candidates)) {
        return [
            'success' => false,
            'target_id' => $target_post_id,
            'target_title' => get_the_title($target_post_id),
            'message' => __('No suitable indexable source pages were found.', 'prorank-seo'),
        ];
    }

    $related_block = build_orphan_fix_related_block($target_post_id, $target_url);
    $link_attributes = [
        'title' => get_the_title($target_post_id),
    ];

    foreach ($source_candidates as $candidate) {
        /** @var \WP_Post $source */
        $source = $candidate['post'];
        $source_content = (string) ($candidate['content'] ?? '');
        $can_append = $allow_append_fallback
            && (((int) ($candidate['score'] ?? 0) > 0) || !empty($preferred_source_ids))
            && source_post_allows_orphan_append($source);

        if ($can_append && source_post_prefers_orphan_append($source)) {
            if (BuilderContentSupport::append_html_block((int) $source->ID, $related_block)) {
                mark_orphan_target_connected($target_post_id);

                return [
                    'success' => true,
                    'target_id' => $target_post_id,
                    'target_title' => get_the_title($target_post_id),
                    'source_id' => (int) $source->ID,
                    'source_title' => get_the_title($source),
                    'method' => 'append',
                    'anchor_text' => get_the_title($target_post_id),
                    'links_created' => 1,
                ];
            }
        }

        foreach ($anchor_candidates as $anchor_text) {
            if ($anchor_text === '' || stripos($source_content, $anchor_text) === false) {
                continue;
            }

            if (BuilderContentSupport::replace_first_anchor_with_link(
                (int) $source->ID,
                $anchor_text,
                $target_url,
                $link_attributes
            )) {
                mark_orphan_target_connected($target_post_id);

                return [
                    'success' => true,
                    'target_id' => $target_post_id,
                    'target_title' => get_the_title($target_post_id),
                    'source_id' => (int) $source->ID,
                    'source_title' => get_the_title($source),
                    'method' => 'inline',
                    'anchor_text' => $anchor_text,
                    'links_created' => 1,
                ];
            }
        }

        if ($can_append) {
            if (BuilderContentSupport::append_html_block((int) $source->ID, $related_block)) {
                mark_orphan_target_connected($target_post_id);

                return [
                    'success' => true,
                    'target_id' => $target_post_id,
                    'target_title' => get_the_title($target_post_id),
                    'source_id' => (int) $source->ID,
                    'source_title' => get_the_title($source),
                    'method' => 'append',
                    'anchor_text' => get_the_title($target_post_id),
                    'links_created' => 1,
                ];
            }
        }
    }

    return [
        'success' => false,
        'target_id' => $target_post_id,
        'target_title' => get_the_title($target_post_id),
        'message' => __('No safe source page could be updated for this orphan.', 'prorank-seo'),
    ];
}

/**
 * Build candidate source posts for orphan fixing.
 *
 * @param \WP_Post $target                Target post.
 * @param array    $preferred_source_ids  Optional source IDs to prefer.
 * @return array<int,array{post:\WP_Post,content:string,score:int}>
 */
function get_orphan_fix_source_candidates(\WP_Post $target, array $preferred_source_ids = []): array {
    $runtime_settings = function_exists(__NAMESPACE__ . '\get_internal_linking_runtime_settings')
        ? get_internal_linking_runtime_settings()
        : [];

    $post_types = function_exists(__NAMESPACE__ . '\get_internal_linking_scoped_post_types')
        ? get_internal_linking_scoped_post_types($runtime_settings)
        : ($runtime_settings['link_post_types'] ?? $runtime_settings['post_types'] ?? ['post', 'page']);

    $post_statuses = function_exists(__NAMESPACE__ . '\get_internal_linking_scoped_post_statuses')
        ? get_internal_linking_scoped_post_statuses($runtime_settings)
        : ['publish'];

    $max_age_days = function_exists(__NAMESPACE__ . '\get_internal_linking_max_age_days')
        ? get_internal_linking_max_age_days($runtime_settings)
        : null;

    $preferred_source_types = [];
    if (!empty($preferred_source_ids)) {
        foreach ($preferred_source_ids as $preferred_source_id) {
            $preferred_post = get_post((int) $preferred_source_id);
            if ($preferred_post instanceof \WP_Post && $preferred_post->post_type !== '') {
                $preferred_source_types[] = (string) $preferred_post->post_type;
            }
        }
    }

    $effective_post_types = !empty($preferred_source_types)
        ? array_values(array_unique($preferred_source_types))
        : (is_array($post_types) ? array_values($post_types) : ['post', 'page']);

    $query_args = [
        'post_type'              => $effective_post_types,
        'post_status'            => is_array($post_statuses) ? array_values($post_statuses) : ['publish'],
        'post__not_in'           => [(int) $target->ID],
        'numberposts'            => !empty($preferred_source_ids) ? count($preferred_source_ids) : 80,
        'no_found_rows'          => true,
        'update_post_meta_cache' => false,
        'update_post_term_cache' => false,
        'suppress_filters'       => false,
    ];

    if (!empty($preferred_source_ids)) {
        $query_args['post__in'] = $preferred_source_ids;
        $query_args['orderby'] = 'post__in';
    } else {
        $query_args['orderby'] = 'date';
        $query_args['order'] = 'DESC';
    }

    if ($max_age_days !== null) {
        $query_args['date_query'] = [
            [
                'after' => sprintf('%d days ago', max(1, (int) $max_age_days)),
                'inclusive' => true,
            ],
        ];
    }

    $source_posts = get_posts($query_args);
    $target_signals = get_orphan_fix_target_signals($target);
    $target_url = (string) get_permalink($target);
    $candidates = [];

    foreach ($source_posts as $source) {
        if (!$source instanceof \WP_Post || !can_use_post_as_orphan_source($source, (int) $target->ID)) {
            continue;
        }

        $skip_existing_link_check = !empty($preferred_source_ids)
            && source_post_prefers_orphan_append($source)
            && source_post_allows_orphan_append($source);
        if (!$skip_existing_link_check && source_post_already_links_to_target($source, (int) $target->ID, $target_url)) {
            continue;
        }

        $source_content = get_post_linking_content((int) $source->ID, (string) $source->post_content);
        if (trim($source_content) === '') {
            continue;
        }

        $score = score_orphan_fix_source($source, $source_content, $target_signals);
        if (empty($preferred_source_ids) && $score < 1) {
            continue;
        }

        $candidates[] = [
            'post' => $source,
            'content' => $source_content,
            'score' => $score,
        ];
    }

    usort($candidates, static function(array $left, array $right): int {
        return (int) ($right['score'] ?? 0) <=> (int) ($left['score'] ?? 0);
    });

    return $candidates;
}

/**
 * Build target phrases and terms for source scoring.
 *
 * @param \WP_Post $target Target post.
 * @return array{phrases:array<int,string>,terms:array<int,string>}
 */
function get_orphan_fix_target_signals(\WP_Post $target): array {
    $phrases = [];
    $terms = [];

    $focus_keyword = FocusKeywords::get_primary($target->ID);
    if ($focus_keyword === '') {
        $focus_keyword = (string) get_post_meta($target->ID, '_yoast_wpseo_focuskw', true);
    }

    $slug_phrase = str_replace('-', ' ', (string) $target->post_name);
    foreach ([$focus_keyword, (string) $target->post_title, $slug_phrase] as $phrase_source) {
        $phrase_source = trim($phrase_source);
        if ($phrase_source === '') {
            continue;
        }

        $phrases[] = $phrase_source;
        $terms = array_merge($terms, extract_orphan_fix_terms($phrase_source));
    }

    $phrases = array_values(array_unique(array_filter($phrases)));
    usort($phrases, static fn (string $left, string $right): int => strlen($right) <=> strlen($left));

    return [
        'phrases' => $phrases,
        'terms' => array_values(array_unique(array_filter($terms))),
    ];
}

/**
 * Build ordered anchor candidates for a target orphan.
 *
 * @param \WP_Post $target Target post.
 * @return array<int,string>
 */
function get_orphan_fix_anchor_candidates(\WP_Post $target): array {
    $signals = get_orphan_fix_target_signals($target);
    $candidates = $signals['phrases'];

    $title_terms = extract_orphan_fix_terms((string) $target->post_title);
    $term_count = count($title_terms);
    for ($length = min(3, $term_count); $length >= 2; $length--) {
        for ($index = 0; $index <= $term_count - $length; $index++) {
            $candidates[] = implode(' ', array_slice($title_terms, $index, $length));
        }
    }

    foreach ($signals['terms'] as $term) {
        if (strlen($term) >= 4) {
            $candidates[] = $term;
        }
    }

    return array_values(array_unique(array_filter(array_map('trim', $candidates))));
}

/**
 * Extract useful non-stopword terms from text.
 *
 * @param string $text Source text.
 * @return array<int,string>
 */
function extract_orphan_fix_terms(string $text): array {
    $tokens = preg_split('/[^a-z0-9]+/i', strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [];
    $stop_words = [
        'the', 'and', 'for', 'with', 'from', 'your', 'that', 'this', 'have', 'will',
        'into', 'about', 'what', 'when', 'where', 'which', 'their', 'there', 'here',
        'services', 'service', 'page', 'post', 'blog', 'guide', 'tips', 'best',
    ];

    return array_values(array_filter($tokens, static function(string $token) use ($stop_words): bool {
        return strlen($token) >= 4 && !in_array($token, $stop_words, true);
    }));
}

/**
 * Score one candidate source post for orphan repair.
 *
 * @param \WP_Post $source         Candidate source post.
 * @param string   $source_content Builder-aware source content.
 * @param array    $target_signals Target phrases and terms.
 * @return int
 */
function score_orphan_fix_source(\WP_Post $source, string $source_content, array $target_signals): int {
    $source_title = strtolower((string) get_the_title($source));
    $normalized_content = strtolower(wp_strip_all_tags($source_content));
    $score = 0;

    foreach ((array) ($target_signals['phrases'] ?? []) as $phrase) {
        $phrase = strtolower(trim((string) $phrase));
        if ($phrase === '') {
            continue;
        }

        if (str_contains($source_title, $phrase)) {
            $score += 24;
        }

        if (str_contains($normalized_content, $phrase)) {
            $score += 14;
        }
    }

    foreach ((array) ($target_signals['terms'] ?? []) as $term) {
        $term = strtolower(trim((string) $term));
        if ($term === '') {
            continue;
        }

        if (str_contains($source_title, $term)) {
            $score += 8;
        }

        if (str_contains($normalized_content, $term)) {
            $score += 4;
        }
    }

    if ((int) get_option('page_on_front') === (int) $source->ID) {
        $score += 6;
    }

    if ($source->post_type === 'page') {
        $score += 2;
    }

    return $score;
}

/**
 * Check whether a published post can be used as an orphan source.
 *
 * @param \WP_Post $source    Candidate source post.
 * @param int      $target_id Target post ID.
 * @return bool
 */
function can_use_post_as_orphan_source(\WP_Post $source, int $target_id): bool {
    if ((int) $source->ID === $target_id) {
        return false;
    }

    if ($source->post_status !== 'publish') {
        return false;
    }

    return !post_has_orphan_source_noindex((int) $source->ID);
}

/**
 * Check whether the candidate source has been noindexed in ProRank.
 *
 * @param int $post_id Post ID.
 * @return bool
 */
function post_has_orphan_source_noindex(int $post_id): bool {
    foreach (['_prorank_seo_noindex', '_prorank_seo_robots_noindex', '_prorank_robots_noindex'] as $meta_key) {
        $value = get_post_meta($post_id, $meta_key, true);
        if (in_array((string) $value, ['1', 'yes', 'on', 'true', 'noindex'], true)) {
            return true;
        }
    }

    return false;
}

/**
 * Appended orphan-repair blocks are only safe on classic content sources.
 *
 * Builder layouts and key framework pages should never receive a visible fallback
 * block, because that can break the intended layout. Those sources must rely on
 * inline replacement only.
 *
 * @param \WP_Post $source Candidate source post.
 * @return bool
 */
function source_post_allows_orphan_append(\WP_Post $source): bool {
    if (is_orphan_detection_exempt_post($source)) {
        return false;
    }

    if ($source->post_type === 'page') {
        return false;
    }

    $post_id = (int) $source->ID;

    $elementor_data = get_post_meta($post_id, '_elementor_data', true);
    if (is_string($elementor_data) && trim($elementor_data) !== '') {
        return false;
    }

    $beaver_data = get_post_meta($post_id, '_fl_builder_data', true);
    if (!empty($beaver_data)) {
        return false;
    }

    $divi_flag = (string) get_post_meta($post_id, '_et_pb_use_builder', true);
    if (in_array($divi_flag, ['1', 'on', 'yes', 'true'], true)) {
        return false;
    }

    return !(is_string($source->post_content) && str_contains($source->post_content, '[et_pb_'));
}

/**
 * Classic article-style sources can prefer a short appended related-reading block
 * when no safe inline anchor is available.
 *
 * @param \WP_Post $source Candidate source post.
 * @return bool
 */
function source_post_prefers_orphan_append(\WP_Post $source): bool {
    return source_post_allows_orphan_append($source);
}

function is_orphan_detection_exempt_post(\WP_Post $post): bool {
    $front_page_id = (int) get_option('page_on_front');
    $posts_page_id = (int) get_option('page_for_posts');

    if ($front_page_id > 0 && (int) $post->ID === $front_page_id) {
        return true;
    }

    if ($posts_page_id > 0 && (int) $post->ID === $posts_page_id) {
        return true;
    }

    $permalink = (string) get_permalink($post->ID);
    $normalized_permalink = untrailingslashit($permalink);
    $normalized_home = untrailingslashit(home_url('/'));

    return $normalized_permalink !== '' && $normalized_permalink === $normalized_home;
}

/**
 * Check whether a source post already links to the target URL.
 *
 * @param \WP_Post $source     Source post.
 * @param int      $target_id  Target post ID.
 * @param string   $target_url Target URL.
 * @return bool
 */
function source_post_already_links_to_target(\WP_Post $source, int $target_id, string $target_url): bool {
    static $source_link_cache = [];

    $target_path = normalize_internal_link_path((string) wp_parse_url($target_url, PHP_URL_PATH));
    $target_host = (string) wp_parse_url(home_url(), PHP_URL_HOST);

    if (!isset($source_link_cache[$source->ID])) {
        $source_link_cache[$source->ID] = BuilderContentSupport::extract_all_links((int) $source->ID, $source);
    }

    foreach ((array) $source_link_cache[$source->ID] as $linked_url) {
        if (!is_string($linked_url) || $linked_url === '') {
            continue;
        }

        if (resolve_internal_post_id($linked_url) === $target_id) {
            return true;
        }

        $linked_host = (string) wp_parse_url($linked_url, PHP_URL_HOST);
        if ($linked_host !== '' && strcasecmp($linked_host, $target_host) !== 0) {
            continue;
        }

        $linked_path = normalize_internal_link_path((string) wp_parse_url($linked_url, PHP_URL_PATH));
        if ($linked_path === $target_path) {
            return true;
        }
    }

    return false;
}

/**
 * Build a short related-reading block for orphan repair fallback.
 *
 * @param int    $target_post_id Target post ID.
 * @param string $target_url     Target URL.
 * @return string
 */
function build_orphan_fix_related_block(int $target_post_id, string $target_url): string {
    $title = esc_html((string) get_the_title($target_post_id));
    $url = esc_url($target_url);

    return sprintf(
        '<p><strong>%s</strong> <a href="%s" title="%s">%s</a>.</p>',
        esc_html__('Related reading:', 'prorank-seo'),
        $url,
        $title,
        $title
    );
}

/**
 * Mark an orphan target as no longer orphaned after a successful fix.
 *
 * @param int $target_post_id Target post ID.
 * @return void
 */
function mark_orphan_target_connected(int $target_post_id): void {
    update_post_meta($target_post_id, '_prorank_is_orphan', 'no');
    update_post_meta($target_post_id, '_prorank_orphan_check_time', time());
}

/**
 * Apply one internal link suggestion by inserting target URL into source content.
 */
function apply_link(WP_REST_Request $request): WP_REST_Response {
    $source_id = (int) $request->get_param('source_id');
    $target_id = (int) $request->get_param('target_id');
    $anchor_text = trim((string) $request->get_param('anchor_text'));
    $target_url = esc_url_raw((string) $request->get_param('target_url'));

    if ($source_id <= 0 || !get_post($source_id)) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Source post not found', 'prorank-seo'),
        ]);
    }

    if ($target_id > 0) {
        $resolved_target_url = get_permalink($target_id);
        if (is_string($resolved_target_url) && $resolved_target_url !== '') {
            $target_url = $resolved_target_url;
        }
    }

    if ($target_url === '') {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Target URL is required', 'prorank-seo'),
        ]);
    }

    if ($anchor_text === '') {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Anchor text is required', 'prorank-seo'),
        ]);
    }

    $settings = get_internal_linking_settings();
    $site_host = (string) wp_parse_url(home_url(), PHP_URL_HOST);
    $target_host = (string) wp_parse_url($target_url, PHP_URL_HOST);
    $is_internal_target = $target_host === '' || strcasecmp($target_host, $site_host) === 0;

    $link_attributes = [];
    if ($is_internal_target && !empty($settings['open_internal_new_tab'])) {
        $link_attributes['target'] = '_blank';
    } elseif (!$is_internal_target && !empty($settings['open_external_new_tab'])) {
        $link_attributes['target'] = '_blank';
    }

    $rel_tokens = [];
    if (!empty($settings['add_nofollow'])) {
        $rel_tokens[] = 'nofollow';
    }
    if (!empty($link_attributes['target']) && $link_attributes['target'] === '_blank') {
        $rel_tokens[] = 'noopener';
        $rel_tokens[] = 'noreferrer';
    }
    if (!empty($rel_tokens)) {
        $link_attributes['rel'] = implode(' ', array_values(array_unique($rel_tokens)));
    }

    if (!empty($settings['add_title_attribute']) && $target_id > 0) {
        $target_title = get_the_title($target_id);
        if (is_string($target_title) && $target_title !== '') {
            $link_attributes['title'] = $target_title;
        }
    }

    try {
        $applied = BuilderContentSupport::replace_first_anchor_with_link(
            $source_id,
            $anchor_text,
            $target_url,
            $link_attributes
        );
    } catch (\Throwable $e) {
        if (function_exists('prorank_log')) {
            prorank_log('[ProRank SEO] apply_link failed: ' . $e->getMessage());
        }

        return rest_ensure_response([
            'success' => false,
            'message' => __('Could not apply link because updating the source content failed.', 'prorank-seo'),
        ]);
    }

    if (!$applied) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Could not find anchor text in source content', 'prorank-seo'),
        ]);
    }

    return rest_ensure_response([
        'success' => true,
        'message' => __('Link inserted successfully', 'prorank-seo'),
    ]);
}


/**
 * Get auto-link rules
 */
function get_auto_link_rules(WP_REST_Request $request): WP_REST_Response {
    $rules = get_option('prorank_auto_link_rules', []);
    
    // Add link count for each rule
    foreach ($rules as &$rule) {
        $rule['links_count'] = get_option('prorank_auto_link_count_' . $rule['id'], 0);
    }
    
    return rest_ensure_response([
        'success' => true,
        'data' => [
            'rules' => $rules,
        ],
    ]);
}

/**
 * Create auto-link rule
 */
function create_auto_link_rule(WP_REST_Request $request): WP_REST_Response {
    $rules = get_option('prorank_auto_link_rules', []);

    $new_rule = build_auto_link_rule_payload($request);
    if ($new_rule instanceof WP_REST_Response) {
        return $new_rule;
    }

    $rules[] = $new_rule;
    update_option('prorank_auto_link_rules', $rules);
    
    return rest_ensure_response([
        'success' => true,
        'data' => $new_rule,
    ]);
}

/**
 * Build a sanitized auto-link rule payload from a request.
 *
 * @param WP_REST_Request $request Request object.
 * @param array           $existing Existing rule values when updating.
 * @return array|WP_REST_Response
 */
function build_auto_link_rule_payload(WP_REST_Request $request, array $existing = []) {
    $params = $request->get_params();
    $rule = wp_parse_args($existing, [
        'id' => uniqid('rule_'),
        'keyword' => '',
        'target_url' => '',
        'case_sensitive' => false,
        'whole_word' => true,
        'max_links_per_post' => 1,
        'max_total_links' => 10,
        'exclude_posts' => '',
        'priority' => 5,
        'enabled' => true,
        'created' => current_time('mysql'),
    ]);

    if (array_key_exists('keyword', $params)) {
        $rule['keyword'] = sanitize_text_field((string) $request->get_param('keyword'));
    }

    if (array_key_exists('target_url', $params)) {
        $rule['target_url'] = esc_url_raw((string) $request->get_param('target_url'));
    }

    if ($rule['keyword'] === '') {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Keyword is required.', 'prorank-seo'),
        ]);
    }

    if ($rule['target_url'] === '') {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Target URL is required.', 'prorank-seo'),
        ]);
    }

    if (array_key_exists('case_sensitive', $params)) {
        $rule['case_sensitive'] = (bool) $request->get_param('case_sensitive');
    }

    if (array_key_exists('whole_word', $params)) {
        $rule['whole_word'] = (bool) $request->get_param('whole_word');
    }

    if (array_key_exists('max_links_per_post', $params)) {
        $rule['max_links_per_post'] = max(1, absint($request->get_param('max_links_per_post')));
    }

    if (array_key_exists('max_total_links', $params)) {
        $rule['max_total_links'] = absint($request->get_param('max_total_links'));
    }

    if (array_key_exists('exclude_posts', $params)) {
        $rule['exclude_posts'] = sanitize_text_field((string) $request->get_param('exclude_posts'));
    }

    if (array_key_exists('priority', $params)) {
        $rule['priority'] = absint($request->get_param('priority'));
    }

    if (array_key_exists('enabled', $params)) {
        $rule['enabled'] = (bool) $request->get_param('enabled');
    }

    $post_id = url_to_postid($rule['target_url']);
    if ($post_id > 0) {
        $rule['target_id'] = $post_id;
        $rule['target_title'] = get_the_title($post_id);
    } else {
        unset($rule['target_id']);
        unset($rule['target_title']);
    }

    return $rule;
}

/**
 * Update an auto-link rule.
 */
function update_auto_link_rule(WP_REST_Request $request): WP_REST_Response {
    $rule_id = sanitize_text_field((string) $request->get_param('id'));
    $rules = get_option('prorank_auto_link_rules', []);

    foreach ($rules as $index => $rule) {
        if (($rule['id'] ?? '') !== $rule_id) {
            continue;
        }

        $updated_rule = build_auto_link_rule_payload($request, $rule);
        if ($updated_rule instanceof WP_REST_Response) {
            return $updated_rule;
        }

        $rules[$index] = $updated_rule;
        update_option('prorank_auto_link_rules', array_values($rules));

        return rest_ensure_response([
            'success' => true,
            'data' => $updated_rule,
        ]);
    }

    return rest_ensure_response([
        'success' => false,
        'message' => __('Auto-link rule not found.', 'prorank-seo'),
    ]);
}

/**
 * Delete an auto-link rule.
 */
function delete_auto_link_rule(WP_REST_Request $request): WP_REST_Response {
    $rule_id = sanitize_text_field((string) $request->get_param('id'));
    $rules = get_option('prorank_auto_link_rules', []);
    $found = false;

    foreach ($rules as $index => $rule) {
        if (($rule['id'] ?? '') !== $rule_id) {
            continue;
        }

        unset($rules[$index]);
        $found = true;
        break;
    }

    if (!$found) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Auto-link rule not found.', 'prorank-seo'),
        ]);
    }

    update_option('prorank_auto_link_rules', array_values($rules));
    delete_option('prorank_auto_link_count_' . $rule_id);

    return rest_ensure_response([
        'success' => true,
        'message' => __('Auto-link rule deleted.', 'prorank-seo'),
    ]);
}

/**
 * Toggle an auto-link rule enabled state.
 */
function toggle_auto_link_rule(WP_REST_Request $request): WP_REST_Response {
    $rule_id = sanitize_text_field((string) $request->get_param('id'));
    $rules = get_option('prorank_auto_link_rules', []);
    $params = $request->get_params();

    foreach ($rules as $index => $rule) {
        if (($rule['id'] ?? '') !== $rule_id) {
            continue;
        }

        $rules[$index]['enabled'] = array_key_exists('enabled', $params)
            ? (bool) $request->get_param('enabled')
            : empty($rule['enabled']);

        update_option('prorank_auto_link_rules', array_values($rules));

        return rest_ensure_response([
            'success' => true,
            'data' => $rules[$index],
        ]);
    }

    return rest_ensure_response([
        'success' => false,
        'message' => __('Auto-link rule not found.', 'prorank-seo'),
    ]);
}

/**
 * Get analysis report
 */
function get_analysis_report(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $settings = get_internal_linking_runtime_settings();

    if (empty($settings['enable_link_analysis'])) {
        return rest_ensure_response([
            'success' => true,
            'data' => [
                'summary' => [
                    'total_posts' => 0,
                    'total_internal_links' => 0,
                    'avg_links_per_post' => 0,
                    'orphaned_pages' => 0,
                    'link_coverage' => 0,
                    'link_health_score' => 0,
                ],
                'top_linking_pages' => [],
                'top_linked_pages' => [],
                'orphaned_pages_list' => [],
                'anchor_analysis' => [],
            ],
            'message' => __('Links Report analytics is disabled in Internal Linking settings.', 'prorank-seo'),
        ]);
    }
    
    $range = $request->get_param('range') ?: '30days';
    $post_type = $request->get_param('post_type') ?: 'all';
    
    $days = 0;
    if ($range !== 'all') {
        $days = (int) str_replace('days', '', $range);
    }
    
    $configured_post_types = get_internal_linking_scoped_post_types($settings);
    $configured_post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);

    // Build post type condition (respect settings scope first).
    $post_types = $post_type === 'all' ? $configured_post_types : (array) $post_type;
    $post_types = array_values(array_filter(array_map('sanitize_key', $post_types)));
    if ($post_type !== 'all') {
        $post_types = array_values(array_intersect($configured_post_types, $post_types));
    }
    if (empty($post_types)) {
        return rest_ensure_response([
            'success' => true,
            'data' => [
                'summary' => [
                    'total_posts' => 0,
                    'total_internal_links' => 0,
                    'avg_links_per_post' => 0,
                    'orphaned_pages' => 0,
                    'link_coverage' => 0,
                    'link_health_score' => 0,
                ],
                'top_linking_pages' => [],
                'top_linked_pages' => [],
                'orphaned_pages_list' => [],
                'anchor_analysis' => [],
            ],
            'message' => __('No eligible post types are enabled in Internal Linking settings.', 'prorank-seo'),
        ]);
    }

    $post_status_placeholders = implode(',', array_fill(0, count($configured_post_statuses), '%s'));
    $post_type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $query = "SELECT ID, post_title, post_content, post_date 
        FROM {$wpdb->posts} 
        WHERE post_status IN ({$post_status_placeholders})
        AND post_type IN ({$post_type_placeholders})";
    $args = array_merge($configured_post_statuses, $post_types);

    if ($days > 0) {
        $query .= ' AND post_date > DATE_SUB(NOW(), INTERVAL %d DAY)';
        $args[] = $days;
    }

    if ($max_age_days !== null) {
        $query .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $args[] = $max_age_days;
    }

    $posts = $wpdb->get_results($wpdb->prepare($query, ...$args)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    
    $total_posts = count($posts);
    $total_internal_links = 0;
    $orphaned_count = 0;
    $link_data = [];
    $anchor_data = [];
    
    foreach ($posts as $post) {
        $permalink = get_permalink($post->ID);
        $internal_count = 0;
        
        // Count internal links
        if (preg_match_all('/<a[^>]+href=["\']([^"\']+)["\'][^>]*>([^<]+)<\/a>/i', $post->post_content, $matches)) {
            foreach ($matches[1] as $index => $url) {
                if (strpos($url, get_site_url()) !== false || strpos($url, '/') === 0) {
                    $internal_count++;
                    $total_internal_links++;
                    
                    // Track anchor text
                    $anchor = $matches[2][$index];
                    if (!isset($anchor_data[$anchor])) {
                        $anchor_data[$anchor] = 0;
                    }
                    $anchor_data[$anchor]++;
                }
            }
        }
        
        // Check if orphaned
        $inbound_query = "SELECT 1 FROM {$wpdb->posts}
            WHERE post_status IN ({$post_status_placeholders})
            AND post_type IN ({$post_type_placeholders})
            AND ID != %d
            AND post_content LIKE %s";
        $inbound_args = array_merge(
            $configured_post_statuses,
            $post_types,
            [
                (int) $post->ID,
                '%' . $wpdb->esc_like($permalink) . '%',
            ]
        );
        if ($max_age_days !== null) {
            $inbound_query .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
            $inbound_args[] = $max_age_days;
        }
        $inbound_query .= ' LIMIT 1';

        $has_inbound = $wpdb->get_var($wpdb->prepare($inbound_query, ...$inbound_args)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        
        if (!$has_inbound) {
            $orphaned_count++;
        }
        
        $link_data[] = [
            'id' => $post->ID,
            'title' => $post->post_title,
            'url' => $permalink,
            'edit_url' => get_edit_post_link($post->ID, 'display'),
            'outbound_count' => $internal_count,
            'inbound_count' => $has_inbound ? 1 : 0,
            'link_density' => str_word_count(wp_strip_all_tags($post->post_content)) > 0 
                ? round(($internal_count / str_word_count(wp_strip_all_tags($post->post_content))) * 100, 2)
                : 0,
        ];
    }
    
    // Sort for top linked/linking
    usort($link_data, function($a, $b) {
        return $b['outbound_count'] - $a['outbound_count'];
    });
    $top_linking = array_slice($link_data, 0, 10);
    
    usort($link_data, function($a, $b) {
        return $b['inbound_count'] - $a['inbound_count'];
    });
    $top_linked = array_slice($link_data, 0, 10);
    
    // Prepare anchor analysis with real diversity calculation
    arsort($anchor_data);
    $anchor_analysis = [];
    $total_anchors = array_sum($anchor_data);
    
    foreach (array_slice($anchor_data, 0, 20, true) as $text => $count) {
        // Calculate real diversity score based on usage percentage
        $usage_percentage = $total_anchors > 0 ? ($count / $total_anchors) * 100 : 0;
        // Higher diversity score for less frequently used anchors
        $diversity_score = min(100, max(0, 100 - ($usage_percentage * 2)));
        
        $anchor_analysis[] = [
            'text' => $text,
            'count' => $count,
            'target_count' => $count, // Real count of unique targets
            'diversity_score' => round($diversity_score),
        ];
    }
    
    // Calculate health score
    $avg_links = $total_posts > 0 ? $total_internal_links / $total_posts : 0;
    $orphan_percentage = $total_posts > 0 ? ($orphaned_count / $total_posts) * 100 : 0;
    $health_score = max(0, min(100, 
        50 + // Base score
        ($avg_links >= 3 ? 20 : $avg_links * 6) + // Link coverage
        (100 - $orphan_percentage) * 0.3 // Orphan penalty
    ));
    
    return rest_ensure_response([
        'success' => true,
        'data' => [
            'summary' => [
                'total_posts' => $total_posts,
                'total_internal_links' => $total_internal_links,
                'average_links_per_post' => round($avg_links, 1),
                'orphaned_posts' => $orphaned_count,
                'broken_links' => 0, // Would need separate check
                'health_score' => round($health_score),
            ],
            'top_linked' => $top_linked,
            'top_linking' => $top_linking,
            'anchor_analysis' => $anchor_analysis,
            'recommendations' => generate_recommendations($avg_links, $orphan_percentage),
        ],
    ]);
}

/**
 * Generate recommendations based on analysis
 */
function generate_recommendations($avg_links, $orphan_percentage) {
    $recommendations = [];
    
    if ($avg_links < 3) {
        $recommendations[] = [
            'type' => 'warning',
            'title' => __('Low Internal Link Density', 'prorank-seo'),
            'message' => __('Your average links per post is below recommended levels. Consider adding more relevant internal links.', 'prorank-seo'),
        ];
    }
    
    if ($orphan_percentage > 20) {
        $recommendations[] = [
            'type' => 'error',
            'title' => __('High Orphan Rate', 'prorank-seo'),
/* translators: %1$: value, %2$: value */
            'message' => sprintf(__('%1$d%%2$ of your posts have no internal links pointing to them. Use the Orphaned Content tool to fix this.', 'prorank-seo'), round($orphan_percentage)),
        ];
    }
    
    if (empty($recommendations)) {
        $recommendations[] = [
            'type' => 'success',
            'title' => __('Good Link Health', 'prorank-seo'),
            'message' => __('Your internal linking structure looks healthy. Keep up the good work!', 'prorank-seo'),
        ];
    }
    
    return $recommendations;
}

/**
 * Get keyword recommendations for a post
 */
function get_post_keywords(WP_REST_Request $request): WP_REST_Response {
    $post_id = (int) $request->get_param('id');
    
    if (!$post_id || !get_post($post_id)) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Invalid post ID', 'prorank-seo'),
        ]);
    }
    
    $post = get_post($post_id);
    $sources = [];
    $keywords = [];

    $focus_kws = FocusKeywords::get($post_id);
    if (!empty($focus_kws)) {
        $focus_strings = array_column($focus_kws, 'keyword');
        $sources['focus'] = $focus_strings;
        $keywords = array_merge($keywords, $focus_strings);
    } else {
        $yoast_kw = (string) get_post_meta($post_id, '_yoast_wpseo_focuskw', true);
        if ($yoast_kw !== '') {
            $sources['focus'] = [$yoast_kw];
            $keywords[] = $yoast_kw;
        }
    }

    $title_keywords = extract_local_internal_linking_keywords((string) $post->post_title);
    if (!empty($title_keywords)) {
        $sources['title'] = $title_keywords;
        $keywords = array_merge($keywords, $title_keywords);
    }

    $slug_keywords = extract_local_internal_linking_keywords(str_replace('-', ' ', (string) $post->post_name));
    if (!empty($slug_keywords)) {
        $sources['slug'] = $slug_keywords;
        $keywords = array_merge($keywords, $slug_keywords);
    }

    $category_names = wp_get_post_terms($post_id, 'category', ['fields' => 'names']);
    if (!is_wp_error($category_names) && !empty($category_names)) {
        $sources['categories'] = array_values(array_filter(array_map('strval', $category_names)));
        $keywords = array_merge($keywords, $sources['categories']);
    }

    $tag_names = wp_get_post_terms($post_id, 'post_tag', ['fields' => 'names']);
    if (!is_wp_error($tag_names) && !empty($tag_names)) {
        $sources['tags'] = array_values(array_filter(array_map('strval', $tag_names)));
        $keywords = array_merge($keywords, $sources['tags']);
    }

    $keywords = array_values(array_unique(array_filter(array_map('trim', $keywords))));
    
    return rest_ensure_response([
        'success' => true,
        'data' => [
            'keywords' => $keywords,
            'sources' => $sources,
            'total' => count($keywords),
        ],
    ]);
}

/**
 * Extract locally-derived keyword candidates for free internal-linking workflows.
 */
function extract_local_internal_linking_keywords(string $text): array {
    $text = strtolower(preg_replace('/[^a-zA-Z0-9\s-]/', ' ', $text));
    $text = preg_replace('/\s+/', ' ', trim($text));

    if ($text === '') {
        return [];
    }

    $stop_words = ['the', 'is', 'at', 'which', 'on', 'a', 'an', 'as', 'are', 'was', 'were', 'to', 'in', 'for', 'of', 'with', 'by'];
    $words = array_values(array_filter(explode(' ', $text), static function($word) use ($stop_words): bool {
        return $word !== '' && strlen($word) > 2 && !in_array($word, $stop_words, true);
    }));

    if (empty($words)) {
        return [];
    }

    $keywords = $words;
    $word_count = count($words);

    for ($i = 0; $i < $word_count - 1; $i++) {
        $keywords[] = $words[$i] . ' ' . $words[$i + 1];
    }

    return array_values(array_unique(array_filter($keywords)));
}

/**
 * Get scan info
 */
function get_scan_info(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $settings = get_internal_linking_runtime_settings();
    $post_types = get_internal_linking_scoped_post_types($settings);
    $post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);

    $status_placeholders = implode(',', array_fill(0, count($post_statuses), '%s'));
    $type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $scope_sql = "post_status IN ({$status_placeholders}) AND post_type IN ({$type_placeholders})";
    $scope_args = array_merge($post_statuses, $post_types);
    if ($max_age_days !== null) {
        $scope_sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $scope_args[] = $max_age_days;
    }

    $post_count = (int) $wpdb->get_var(
        $wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->posts} WHERE {$scope_sql}", ...$scope_args) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );
    $link_count = (int) $wpdb->get_var(
        $wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->posts} WHERE {$scope_sql} AND post_content LIKE %s", ...array_merge($scope_args, ['%href=%'])) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    $scan_history = get_internal_linking_scan_history(1);
    $latest_snapshot = $scan_history[0] ?? null;

    $last_scan = (string) get_option('prorank_last_full_scan', '');
    if ($last_scan === '') {
        $last_scan = (string) get_option('prorank_last_link_scan', '');
    }
    if ($last_scan === '' && is_array($latest_snapshot)) {
        $last_scan = (string) ($latest_snapshot['scanned_at'] ?? '');
    }

    $total_posts = (int) $post_count;
    $internal_links = 0;
    $posts_with_links = 0;
    $unique_internal_targets = 0;
    $orphaned_posts = 0;
    $broken_links = 0;

    if (is_array($latest_snapshot)) {
        $total_posts = max(0, (int) ($latest_snapshot['total_posts'] ?? $total_posts));
        $internal_links = max(0, (int) ($latest_snapshot['total_internal_links'] ?? $internal_links));
        $posts_with_links = max(0, (int) ($latest_snapshot['posts_with_links'] ?? $posts_with_links));
        $unique_internal_targets = max(0, (int) ($latest_snapshot['unique_internal_targets'] ?? $unique_internal_targets));
        $orphaned_posts = max(0, (int) ($latest_snapshot['orphan_count'] ?? $orphaned_posts));
        $broken_links = max(0, (int) ($latest_snapshot['broken_links'] ?? $broken_links));
    } elseif ($last_scan !== '') {
        // Backward compatibility for older installs that have timestamp/options
        // but no persisted scan-history snapshot yet.
        $internal_links = (int) $link_count;
        $posts_with_links = (int) max(0, $total_posts - (int) get_option('prorank_orphaned_count', 0));
        $orphaned_posts = (int) get_option('prorank_orphaned_count', 0);
        $broken_links = (int) get_option('prorank_broken_links_count', 0);
    }

    return rest_ensure_response([
        'success' => true,
        'data' => [
            'last_scan' => $last_scan ?: null,
            'stats' => [
                'total_posts' => $total_posts,
                'internal_links' => $internal_links,
                'posts_with_links' => $posts_with_links,
                'unique_internal_targets' => $unique_internal_targets,
                'orphaned_posts' => $orphaned_posts,
                'broken_links' => $broken_links,
                'suggestions' => (int) get_option('prorank_suggestions_count', 0),
            ],
        ],
    ]);
}

/**
 * Get operations automation state and recent cycle runs.
 */
function get_linking_operations_state(WP_REST_Request $request): WP_REST_Response {
    $limit = (int) $request->get_param('limit');
    $limit = $limit > 0 ? min(20, $limit) : 8;

    return rest_ensure_response([
        'success' => true,
        'data' => OperationsManager::get_payload($limit),
    ]);
}

/**
 * Update operations automation settings.
 */
function update_linking_operations_state(WP_REST_Request $request): WP_REST_Response {
    $params = $request->get_json_params();
    if (!is_array($params)) {
        $params = $request->get_params();
    }

    OperationsManager::update_state((array) $params);

    return rest_ensure_response([
        'success' => true,
        'data' => OperationsManager::get_payload(8),
        'message' => __('Operations automation settings saved.', 'prorank-seo'),
    ]);
}

/**
 * Run operations automation cycle immediately.
 */
function run_linking_operations_cycle(WP_REST_Request $request): WP_REST_Response {
    $triggered_by = current_user_can('manage_options') ? 'manual_admin' : 'manual_editor';
    $run = OperationsManager::run_cycle($triggered_by);

    return rest_ensure_response([
        'success' => ($run['status'] ?? 'failed') === 'success',
        'message' => (string) ($run['message'] ?? ''),
        'data' => [
            'latest_run' => $run,
            'operations' => OperationsManager::get_payload(8),
        ],
    ]);
}

/**
 * Get internal-linking table readiness and schema health details.
 */
function get_table_readiness(WP_REST_Request $request): WP_REST_Response {
    $repair = rest_sanitize_boolean($request->get_param('repair'));
    $include_columns = rest_sanitize_boolean($request->get_param('include_columns'));

    if ($repair) {
        $result = TableRegistry::ensure_tables(true, true);
    } else {
        $result = ensure_internal_linking_schema_ready(false);
        if ($include_columns) {
            $result = TableRegistry::get_readiness_report(true, true);
        }
    }

    return rest_ensure_response([
        'success' => true,
        'data' => $result,
        'meta' => [
            'checked_at' => current_time('mysql'),
        ],
    ]);
}

/**
 * Additional stub functions for remaining endpoints
 */

function get_scan_progress(WP_REST_Request $request): WP_REST_Response {
    $scan_id = $request->get_param('scan_id');
    
    // Get scan data from transient
    $scan_data = get_transient('prorank_scan_' . $scan_id);
    
    if (!$scan_data) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Scan not found', 'prorank-seo'),
        ]);
    }
    
    return rest_ensure_response([
        'success' => true,
        'progress' => $scan_data['progress'] ?? 0,
        'status' => $scan_data['status'] ?? __('Processing...', 'prorank-seo'),
        'current' => $scan_data['current'] ?? 0,
        'total' => $scan_data['total'] ?? 0,
        'broken_count' => $scan_data['broken_count'] ?? 0,
        'completed' => $scan_data['completed'] ?? false,
    ]);
}

/**
 * Get general link scan progress (without scan_id)
 * Uses WordPress options to track the current scan status
 */
function get_general_scan_progress(WP_REST_Request $request): WP_REST_Response {
    // Get scan status from options
    $status = get_option('prorank_link_scan_status', 'idle');
    $progress = (int) get_option('prorank_link_scan_progress', 0);
    $current = (int) get_option('prorank_link_scan_current', 0);
    $total = (int) get_option('prorank_link_scan_total', 0);
    $message = get_option('prorank_link_scan_message', '');

    // Calculate percentage if we have valid total
    if ($total > 0 && $progress === 0) {
        $progress = round(($current / $total) * 100);
    }

    // Check if scan is complete
    $is_complete = $status === 'complete' || $progress >= 100;
    if ($is_complete && $total > 0 && $current < $total) {
        $current = $total;
    }

    $last_scan = (string) get_option('prorank_last_link_scan', '');
    if ($last_scan === '') {
        $last_scan = (string) get_option('prorank_last_full_scan', '');
    }
    if ($last_scan === '') {
        $history = get_internal_linking_scan_history(1);
        if (!empty($history[0]['scanned_at'])) {
            $last_scan = (string) $history[0]['scanned_at'];
        }
    }

    return rest_ensure_response([
        'success' => true,
        'status' => $is_complete ? 'complete' : $status,
        'progress' => min($progress, 100),
        'current' => $current,
        'total' => $total,
        'message' => $message,
        'last_scan' => $last_scan !== '' ? $last_scan : null,
    ]);
}

function get_auto_link_stats(WP_REST_Request $request): WP_REST_Response {
    $rules = get_option('prorank_auto_link_rules', []);
    
    return rest_ensure_response([
        'success' => true,
        'data' => [
            'total_rules' => count($rules),
            'active_rules' => count(array_filter($rules, function($r) { return $r['enabled'] ?? false; })),
            'links_created' => (int) get_option('prorank_auto_links_created', 0),
            'last_run' => get_option('prorank_auto_link_last_run'),
            'current_job' => get_public_auto_link_batch_progress(),
        ],
    ]);
}

/**
 * Get persisted auto-link batch state.
 *
 * @return array
 */
function get_auto_link_batch_progress_state(): array {
    $progress = get_option(AUTO_LINK_BATCH_PROGRESS_OPTION, []);

    if (!is_array($progress) || empty($progress['job_id'])) {
        return [
            'job_id' => null,
            'status' => 'idle',
            'message' => '',
            'total_posts' => 0,
            'processed_posts' => 0,
            'posts_updated' => 0,
            'links_created' => 0,
            'error_count' => 0,
            'errors' => [],
            'current_offset' => 0,
            'batch_size' => AUTO_LINK_BATCH_SIZE,
            'started_at' => null,
            'updated_at' => null,
            'completed_at' => null,
        ];
    }

    $progress['total_posts'] = max(0, (int) ($progress['total_posts'] ?? 0));
    $progress['processed_posts'] = max(0, (int) ($progress['processed_posts'] ?? 0));
    $progress['posts_updated'] = max(0, (int) ($progress['posts_updated'] ?? 0));
    $progress['links_created'] = max(0, (int) ($progress['links_created'] ?? 0));
    $progress['error_count'] = max(0, (int) ($progress['error_count'] ?? 0));
    $progress['current_offset'] = max(0, (int) ($progress['current_offset'] ?? 0));
    $progress['batch_size'] = max(1, (int) ($progress['batch_size'] ?? AUTO_LINK_BATCH_SIZE));
    $progress['status'] = sanitize_key((string) ($progress['status'] ?? 'idle'));
    $progress['message'] = sanitize_text_field((string) ($progress['message'] ?? ''));
    $progress['started_at'] = !empty($progress['started_at']) ? (string) $progress['started_at'] : null;
    $progress['updated_at'] = !empty($progress['updated_at']) ? (string) $progress['updated_at'] : null;
    $progress['completed_at'] = !empty($progress['completed_at']) ? (string) $progress['completed_at'] : null;
    $progress['errors'] = array_values(array_filter(array_map('sanitize_text_field', (array) ($progress['errors'] ?? []))));

    return $progress;
}

/**
 * Get public auto-link batch progress payload for the UI.
 *
 * @return array|null
 */
function get_public_auto_link_batch_progress(): ?array {
    $progress = get_auto_link_batch_progress_state();

    if (($progress['status'] ?? 'idle') === 'idle' || empty($progress['job_id'])) {
        return null;
    }

    $percentage = $progress['total_posts'] > 0
        ? (int) min(100, round(($progress['processed_posts'] / $progress['total_posts']) * 100))
        : 0;

    return [
        'job_id' => (string) $progress['job_id'],
        'status' => (string) $progress['status'],
        'message' => (string) $progress['message'],
        'total_posts' => (int) $progress['total_posts'],
        'processed_posts' => (int) $progress['processed_posts'],
        'posts_updated' => (int) $progress['posts_updated'],
        'links_created' => (int) $progress['links_created'],
        'error_count' => (int) $progress['error_count'],
        'errors' => array_slice((array) $progress['errors'], 0, 5),
        'percentage' => $percentage,
        'batch_size' => (int) $progress['batch_size'],
        'started_at' => $progress['started_at'],
        'updated_at' => $progress['updated_at'],
        'completed_at' => $progress['completed_at'],
    ];
}

/**
 * Build the SQL fragments used to select eligible auto-linking posts.
 *
 * @param array    $post_types Eligible post types.
 * @param array    $post_statuses Eligible post statuses.
 * @param int|null $max_age_days Optional age limit.
 * @return array{sql:string,args:array}
 */
function build_auto_link_scope_query_parts(array $post_types, array $post_statuses, ?int $max_age_days): array {
    $status_placeholders = implode(',', array_fill(0, count($post_statuses), '%s'));
    $type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $sql = "post_status IN ({$status_placeholders}) AND post_type IN ({$type_placeholders})";
    $args = array_merge($post_statuses, $post_types);

    if ($max_age_days !== null) {
        $sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $args[] = $max_age_days;
    }

    return [
        'sql' => $sql,
        'args' => $args,
    ];
}

/**
 * Fetch eligible post IDs for auto-linking.
 *
 * @param array    $post_types Eligible post types.
 * @param array    $post_statuses Eligible post statuses.
 * @param int|null $max_age_days Optional age limit.
 * @param int|null $limit Optional post limit.
 * @return int[]
 */
function get_auto_link_scope_post_ids(array $post_types, array $post_statuses, ?int $max_age_days, ?int $limit = null): array {
    global $wpdb;

    $scope = build_auto_link_scope_query_parts($post_types, $post_statuses, $max_age_days);
    $query = "SELECT ID
        FROM {$wpdb->posts}
        WHERE {$scope['sql']}
        ORDER BY post_date DESC";

    if ($limit !== null && $limit > 0) {
        $query .= ' LIMIT %d';
        $scope['args'][] = $limit;
    }

    $post_ids = $wpdb->get_col(
        $wpdb->prepare($query, ...$scope['args']) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    return array_values(array_filter(array_map('absint', (array) $post_ids)));
}

/**
 * Resolve the target post ID for an auto-link rule when possible.
 *
 * @param array $rule Auto-link rule.
 * @return int
 */
function resolve_auto_link_rule_target_id(array $rule): int {
    if (!empty($rule['target_id'])) {
        return absint($rule['target_id']);
    }

    $target_url = (string) ($rule['target_url'] ?? '');
    if ($target_url === '') {
        return 0;
    }

    return resolve_internal_post_id($target_url);
}

/**
 * Build inbound link counts for active auto-link targets.
 *
 * @param array    $active_rules Active rules.
 * @param array    $post_types Eligible post types.
 * @param array    $post_statuses Eligible post statuses.
 * @param int|null $max_age_days Optional age limit.
 * @return array<int,int>
 */
function build_auto_link_target_inbound_counts(array $active_rules, array $post_types, array $post_statuses, ?int $max_age_days): array {
    global $wpdb;

    $counts = [];
    $missing_target_ids = [];
    $report_cache = get_internal_linking_full_report_cache();

    foreach ((array) ($report_cache['posts'] ?? []) as $row) {
        $post_id = (int) ($row['id'] ?? 0);
        if ($post_id <= 0) {
            continue;
        }

        $counts[$post_id] = max(0, (int) ($row['inbound_internal'] ?? 0));
    }

    foreach ($active_rules as $rule) {
        $target_id = resolve_auto_link_rule_target_id((array) $rule);
        if ($target_id > 0 && !array_key_exists($target_id, $counts)) {
            $missing_target_ids[$target_id] = true;
        }
    }

    if (empty($missing_target_ids)) {
        return $counts;
    }

    $scope = build_auto_link_scope_query_parts($post_types, $post_statuses, $max_age_days);
    $posts = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT ID, post_content
            FROM {$wpdb->posts}
            WHERE {$scope['sql']}",
            ...$scope['args']
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    if (empty($posts)) {
        foreach (array_keys($missing_target_ids) as $target_id) {
            $counts[$target_id] = $counts[$target_id] ?? 0;
        }

        return $counts;
    }

    $path_map = [];
    foreach ($posts as $post) {
        $post_permalink = (string) get_permalink((int) $post->ID);
        $normalized_path = normalize_internal_link_path((string) wp_parse_url($post_permalink, PHP_URL_PATH));
        $path_map[$normalized_path] = (int) $post->ID;
    }

    foreach (array_keys($missing_target_ids) as $target_id) {
        $counts[$target_id] = 0;
    }

    foreach ($posts as $post) {
        $post_id = (int) $post->ID;
        $content = get_post_linking_content($post_id, (string) $post->post_content);
        $urls = get_post_linking_urls($post_id, $content);

        foreach ($urls as $raw_url) {
            $resolved_target_id = resolve_internal_post_id(normalize_linking_url((string) $raw_url), $path_map);
            if ($resolved_target_id > 0 && isset($missing_target_ids[$resolved_target_id])) {
                $counts[$resolved_target_id]++;
            }
        }
    }

    return $counts;
}

/**
 * Resolve active auto-link rules and their persisted counts.
 *
 * @param array $rules Auto-link rules.
 * @return array{rules:array,counts:array}
 */
function resolve_active_auto_link_rules(array $rules): array {
    $active_rules = array_values(array_filter($rules, function ($rule) {
        return isset($rule['enabled']) && $rule['enabled'];
    }));

    usort($active_rules, function ($a, $b) {
        return (int) ($b['priority'] ?? 0) - (int) ($a['priority'] ?? 0);
    });

    $rule_counts = [];
    foreach ($active_rules as $rule) {
        $rule_id = (string) ($rule['id'] ?? '');
        if ($rule_id === '') {
            continue;
        }
        $rule_counts[$rule_id] = (int) get_option('prorank_auto_link_count_' . $rule_id, 0);
    }

    return [
        'rules' => $active_rules,
        'counts' => $rule_counts,
    ];
}

/**
 * Safe link inserter - avoids headings, nav, footer, and respects paragraph limits
 *
 * @param string $content Post content
 * @param string $keyword Keyword to link
 * @param string $link_html Link HTML with $0 placeholder for keyword
 * @param int $max_per_post Maximum links per post for this keyword
 * @param int $max_per_paragraph Maximum links per paragraph (default 2)
 * @param array &$used_anchors Array of already used anchor texts (for dedup)
 * @return array ['content' => string, 'count' => int]
 */
function safe_link_insert(
    string $content,
    string $keyword,
    string $link_html,
    int $max_per_post,
    int $max_per_paragraph = 2,
    array &$used_anchors = [],
    bool $skip_sentences_with_links = true,
    bool $whole_word_only = true,
    bool $case_sensitive = false,
    int $skip_initial_sentences = 0
): array {
    // Elements to skip (headings, nav, buttons, etc.)
    $skip_tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'nav', 'footer', 'header', 'button', 'a', 'script', 'style', 'code', 'pre'];

    // Build regex using the rule flags exposed in the UI.
    $pattern_body = '(' . preg_quote($keyword, '/') . ')';
    if ($whole_word_only) {
        $pattern_body = '\b' . $pattern_body . '\b';
    }
    $pattern_modifiers = 'u' . ($case_sensitive ? '' : 'i');
    $pattern = '/' . $pattern_body . '/' . $pattern_modifiers;

    // Track insertions
    $total_count = 0;
    $paragraph_link_counts = [];
    $remaining_skip_sentences = max(0, $skip_initial_sentences);

    // Use DOMDocument for safer manipulation
    $dom = new \DOMDocument();
    libxml_use_internal_errors(true);

    // Wrap content in a div to ensure valid HTML
    $wrapped = '<div>' . $content . '</div>';
    $dom->loadHTML('<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    libxml_clear_errors();

    $xpath = new \DOMXPath($dom);

    // Find all text nodes
    $text_nodes = $xpath->query('//text()');

    foreach ($text_nodes as $text_node) {
        if ($total_count >= $max_per_post) break;

        // Get parent element
        $parent = $text_node->parentNode;
        if (!$parent) continue;

        // Check if inside a skip tag
        $current = $parent;
        $should_skip = false;
        while ($current && $current->nodeName !== 'div') {
            if (in_array(strtolower($current->nodeName), $skip_tags, true)) {
                $should_skip = true;
                break;
            }
            $current = $current->parentNode;
        }
        if ($should_skip) continue;

        // Check paragraph link limit
        $paragraph_id = spl_object_id($parent);
        if (!isset($paragraph_link_counts[$paragraph_id])) {
            $paragraph_link_counts[$paragraph_id] = 0;
        }
        if ($paragraph_link_counts[$paragraph_id] >= $max_per_paragraph) continue;

        // Check for short paragraph (less than 50 chars - skip)
        $paragraph_text = $parent->textContent ?? '';
        if (strlen(trim($paragraph_text)) < 50) continue;
        if (
            $skip_sentences_with_links
            && $parent instanceof \DOMElement
            && $parent->getElementsByTagName('a')->length > 0
        ) {
            continue;
        }

        $text = $text_node->nodeValue;
        $prefix_text = '';
        $matchable_text = $text;

        if ($remaining_skip_sentences > 0) {
            $sentence_matches = [];
            preg_match_all('/[^.!?]+(?:[.!?]+(?:["\')\]]+)?\s*|$)/u', $text, $sentence_matches, PREG_OFFSET_CAPTURE);
            $sentence_matches = array_values(array_filter(
                (array) ($sentence_matches[0] ?? []),
                static function (array $match): bool {
                    return trim((string) ($match[0] ?? '')) !== '';
                }
            ));

            if (!empty($sentence_matches)) {
                $sentences_to_consume = min($remaining_skip_sentences, count($sentence_matches));
                $prefix_end = 0;

                for ($index = 0; $index < $sentences_to_consume; $index++) {
                    $prefix_end = max(
                        $prefix_end,
                        (int) $sentence_matches[$index][1] + strlen((string) $sentence_matches[$index][0])
                    );
                }

                $prefix_text = substr($text, 0, $prefix_end);
                $matchable_text = substr($text, $prefix_end);
                $remaining_skip_sentences -= $sentences_to_consume;
            }
        }

        if ($matchable_text === '' || !preg_match($pattern, $matchable_text)) continue;

        // Check if this anchor was already used (duplicate detection)
        $anchor_lower = strtolower($keyword);
        if (in_array($anchor_lower, $used_anchors, true)) continue;

        // Replace first occurrence
        $new_text = preg_replace_callback(
            $pattern,
            function ($matches) use ($link_html, &$total_count, $max_per_post, &$paragraph_link_counts, $paragraph_id, $max_per_paragraph, &$used_anchors) {
                if ($total_count >= $max_per_post) return $matches[0];
                if ($paragraph_link_counts[$paragraph_id] >= $max_per_paragraph) return $matches[0];

                $total_count++;
                $paragraph_link_counts[$paragraph_id]++;
                $used_anchors[] = strtolower($matches[1]);

                return str_replace('$0', $matches[1], $link_html);
            },
            $matchable_text,
            1 // Only replace first occurrence in this text node
        );

        $new_text = $prefix_text . $new_text;

        if ($new_text !== $text) {
            // Create a temporary placeholder to insert HTML
            $fragment = $dom->createDocumentFragment();
            $fragment->appendXML($new_text);
            $parent->replaceChild($fragment, $text_node);
        }
    }

    // Extract modified content
    $result = $dom->saveHTML();

    // Remove wrapper div and XML declaration
    $result = preg_replace('/^.*?<div>(.*)<\/div>.*$/s', '$1', $result);

    return [
        'content' => $result,
        'count' => $total_count,
    ];
}

/**
 * Apply active auto-link rules to a single post.
 *
 * @param \WP_Post $post Post object.
 * @param array    $active_rules Active rules.
 * @param array    $rule_counts Rule usage counts.
 * @param array    $settings Internal linking settings snapshot.
 * @param int      $max_links Max links per post for this run.
 * @return array
 */
function apply_auto_link_rules_to_post(
    \WP_Post $post,
    array $active_rules,
    array &$rule_counts,
    array &$target_inbound_counts,
    array $settings,
    int $max_links
): array {
    $content = (string) $post->post_content;
    $original_content = $content;
    $links_in_post = 0;
    $post_changes = [];
    $used_anchors = [];

    $max_links_per_paragraph = max(1, (int) ($settings['max_links_per_paragraph'] ?? 2));
    $skip_sentences_with_links = !empty($settings['skip_sentences_with_links']);
    $skip_initial_sentences = max(0, (int) ($settings['skip_sentences'] ?? 0));
    $open_internal_new_tab = !empty($settings['open_internal_new_tab']);
    $open_external_new_tab = !empty($settings['open_external_new_tab']);
    $add_nofollow = !empty($settings['add_nofollow']);
    $add_title_attribute = !empty($settings['add_title_attribute']);
    $max_inbound_links = max(0, (int) ($settings['max_inbound_links'] ?? 0));
    $site_host = (string) wp_parse_url(home_url(), PHP_URL_HOST);

    foreach ($active_rules as $rule) {
        if ($links_in_post >= $max_links) {
            break;
        }

        $keyword = (string) ($rule['keyword'] ?? '');
        $target_url = (string) ($rule['target_url'] ?? '');
        if ($keyword === '' || $target_url === '') {
            continue;
        }

        $max_per_post = max(1, (int) ($rule['max_links_per_post'] ?? 1));
        $whole_word_only = !array_key_exists('whole_word', $rule) || !empty($rule['whole_word']);
        $case_sensitive = !empty($rule['case_sensitive']);
        $rule_id = (string) ($rule['id'] ?? '');
        $existing_rule_count = (int) ($rule_counts[$rule_id] ?? 0);
        $remaining_rule_capacity = null;

        if (!empty($rule['max_total_links'])) {
            $remaining_rule_capacity = max(0, (int) $rule['max_total_links'] - $existing_rule_count);
            if ($remaining_rule_capacity <= 0) {
                continue;
            }
        }

        if (!empty($rule['exclude_posts'])) {
            $excluded = array_map('trim', explode(',', (string) $rule['exclude_posts']));
            if (in_array((string) $post->ID, $excluded, true)) {
                continue;
            }
        }

        if (in_array(strtolower($keyword), $used_anchors, true)) {
            continue;
        }

        if (stripos(wp_strip_all_tags($content), $keyword) === false) {
            continue;
        }

        $target_id = resolve_auto_link_rule_target_id((array) $rule);
        $current_inbound_count = $target_id > 0 ? (int) ($target_inbound_counts[$target_id] ?? 0) : 0;
        $remaining_inbound_capacity = null;

        if ($target_id > 0 && $max_inbound_links > 0) {
            $remaining_inbound_capacity = max(0, $max_inbound_links - $current_inbound_count);
            if ($remaining_inbound_capacity <= 0) {
                continue;
            }
        }

        $is_internal_target = $target_id > 0 || is_internal_linking_url($target_url, $site_host);
        $target_attribute = ($is_internal_target ? $open_internal_new_tab : $open_external_new_tab) ? ' target="_blank"' : '';
        $rel_tokens = [];
        if ($add_nofollow && !$is_internal_target) {
            $rel_tokens[] = 'nofollow';
        }
        if ($target_attribute !== '') {
            $rel_tokens[] = 'noopener';
            $rel_tokens[] = 'noreferrer';
        }
        $rel_attribute = '';
        if (!empty($rel_tokens)) {
            $rel_attribute = ' rel="' . esc_attr(implode(' ', array_values(array_unique($rel_tokens)))) . '"';
        }
        $title_attribute = ($add_title_attribute && !empty($rule['target_title']))
            ? ' title="' . esc_attr((string) $rule['target_title']) . '"'
            : '';

        $link_html = sprintf(
            '<a href="%s"%s%s%s>$0</a>',
            esc_url($target_url),
            $target_attribute,
            $rel_attribute,
            $title_attribute
        );

        $result = safe_link_insert(
            $content,
            $keyword,
            $link_html,
            min(
                $max_per_post,
                $max_links - $links_in_post,
                $remaining_rule_capacity ?? PHP_INT_MAX,
                $remaining_inbound_capacity ?? PHP_INT_MAX
            ),
            $max_links_per_paragraph,
            $used_anchors,
            $skip_sentences_with_links,
            $whole_word_only,
            $case_sensitive,
            $skip_initial_sentences
        );

        if ((int) $result['count'] <= 0) {
            continue;
        }

        $content = (string) $result['content'];
        $links_in_post += (int) $result['count'];
        if ($rule_id !== '') {
            $rule_counts[$rule_id] = $existing_rule_count + (int) $result['count'];
        }
        if ($target_id > 0) {
            $target_inbound_counts[$target_id] = $current_inbound_count + (int) $result['count'];
        }
        $post_changes[] = [
            'keyword' => $keyword,
            'target_url' => $target_url,
            'count' => (int) $result['count'],
        ];
    }

    return [
        'changed' => $content !== $original_content,
        'content' => $content,
        'links_in_post' => $links_in_post,
        'changes' => $post_changes,
        'preview_content' => wp_trim_words($content, 50),
    ];
}

/**
 * Append an auto-link batch error to the persisted state.
 *
 * @param array  $progress Progress state.
 * @param string $message Error message.
 * @return void
 */
function append_auto_link_batch_error(array &$progress, string $message): void {
    $progress['error_count'] = (int) ($progress['error_count'] ?? 0) + 1;
    $progress['errors'] = array_values((array) ($progress['errors'] ?? []));

    if (count($progress['errors']) < 10) {
        $progress['errors'][] = sanitize_text_field($message);
    }
}

/**
 * Schedule the next auto-link batch if the job is still active.
 *
 * @param array $progress Progress state.
 * @return void
 */
function schedule_next_auto_link_batch(array $progress): void {
    if (($progress['status'] ?? '') !== 'processing' || empty($progress['job_id'])) {
        return;
    }

    wp_schedule_single_event(time() + 5, AUTO_LINK_BATCH_HOOK, [(string) $progress['job_id']]);
}

/**
 * Process one auto-linking batch.
 *
 * @param string $job_id Expected job id.
 * @return array
 */
function process_auto_link_batch_event(string $job_id = ''): array {
    $progress = get_auto_link_batch_progress_state();

    if (($progress['status'] ?? 'idle') !== 'processing') {
        return $progress;
    }

    if ($job_id !== '' && (string) ($progress['job_id'] ?? '') !== $job_id) {
        return $progress;
    }

    if (get_transient(AUTO_LINK_BATCH_LOCK_TRANSIENT)) {
        return $progress;
    }

    set_transient(AUTO_LINK_BATCH_LOCK_TRANSIENT, 1, 60);

    try {
        $post_ids = array_values(array_filter(array_map('absint', (array) ($progress['post_ids'] ?? []))));
        $offset = max(0, (int) ($progress['current_offset'] ?? 0));
        $batch_size = max(1, (int) ($progress['batch_size'] ?? AUTO_LINK_BATCH_SIZE));
        $batch_ids = array_slice($post_ids, $offset, $batch_size);

        if (empty($batch_ids)) {
            $progress['status'] = 'completed';
            $progress['message'] = sprintf(
                __('Auto-linking completed. %1$d links were created across %2$d posts.', 'prorank-seo'),
                (int) ($progress['links_created'] ?? 0),
                (int) ($progress['posts_updated'] ?? 0)
            );
            $progress['completed_at'] = current_time('mysql');
            $progress['updated_at'] = $progress['completed_at'];
            update_option(AUTO_LINK_BATCH_PROGRESS_OPTION, $progress);
            update_option('prorank_auto_link_last_run', $progress['completed_at']);
            return $progress;
        }

        $rule_counts = is_array($progress['rule_counts'] ?? null) ? $progress['rule_counts'] : [];
        $target_inbound_counts = is_array($progress['target_inbound_counts'] ?? null) ? $progress['target_inbound_counts'] : [];
        $active_rules = array_values((array) ($progress['active_rules'] ?? []));
        $settings = is_array($progress['settings'] ?? null) ? $progress['settings'] : [];
        $max_links = max(1, (int) ($progress['max_links'] ?? 1));

        foreach ($batch_ids as $post_id) {
            $post = get_post($post_id);
            if (!$post instanceof \WP_Post) {
                append_auto_link_batch_error(
                    $progress,
                    sprintf(__('Post %d could not be loaded during auto-linking.', 'prorank-seo'), $post_id)
                );
                $progress['processed_posts'] = (int) ($progress['processed_posts'] ?? 0) + 1;
                continue;
            }

            $result = apply_auto_link_rules_to_post($post, $active_rules, $rule_counts, $target_inbound_counts, $settings, $max_links);

            if (!empty($result['changed'])) {
                $update_result = wp_update_post([
                    'ID' => $post->ID,
                    'post_content' => (string) $result['content'],
                ], true);

                if (is_wp_error($update_result)) {
                    append_auto_link_batch_error(
                        $progress,
                        sprintf(
                            __('Post %1$d could not be updated: %2$s', 'prorank-seo'),
                            $post->ID,
                            $update_result->get_error_message()
                        )
                    );
                } else {
                    $progress['posts_updated'] = (int) ($progress['posts_updated'] ?? 0) + 1;
                    $progress['links_created'] = (int) ($progress['links_created'] ?? 0) + (int) ($result['links_in_post'] ?? 0);
                    update_option(
                        'prorank_auto_links_created',
                        (int) get_option('prorank_auto_links_created', 0) + (int) ($result['links_in_post'] ?? 0)
                    );
                }
            }

            $progress['processed_posts'] = (int) ($progress['processed_posts'] ?? 0) + 1;
        }

        foreach ($rule_counts as $rule_id => $count) {
            update_option('prorank_auto_link_count_' . $rule_id, (int) $count);
        }

        $progress['rule_counts'] = $rule_counts;
        $progress['target_inbound_counts'] = $target_inbound_counts;
        $progress['current_offset'] = $offset + count($batch_ids);
        $progress['updated_at'] = current_time('mysql');

        if ((int) $progress['processed_posts'] >= (int) $progress['total_posts']) {
            $progress['status'] = 'completed';
            $progress['message'] = sprintf(
                __('Auto-linking completed. %1$d links were created across %2$d posts.', 'prorank-seo'),
                (int) ($progress['links_created'] ?? 0),
                (int) ($progress['posts_updated'] ?? 0)
            );
            $progress['completed_at'] = $progress['updated_at'];
            update_option(AUTO_LINK_BATCH_PROGRESS_OPTION, $progress);
            update_option('prorank_auto_link_last_run', $progress['completed_at']);
            return $progress;
        }

        $progress['message'] = sprintf(
            __('Background run in progress: %1$d of %2$d eligible posts processed.', 'prorank-seo'),
            (int) ($progress['processed_posts'] ?? 0),
            (int) ($progress['total_posts'] ?? 0)
        );
        update_option(AUTO_LINK_BATCH_PROGRESS_OPTION, $progress);
        schedule_next_auto_link_batch($progress);

        return $progress;
    } finally {
        delete_transient(AUTO_LINK_BATCH_LOCK_TRANSIENT);
    }
}

/**
 * Start a full-site auto-link batch run.
 *
 * @param array    $active_rules Active rules.
 * @param array    $rule_counts Rule counts.
 * @param array    $settings Settings snapshot.
 * @param array    $post_types Post types to process.
 * @param array    $post_statuses Post statuses to process.
 * @param int|null $max_age_days Optional age limit.
 * @param int      $max_links Max links per post.
 * @return array
 */
function start_auto_link_batch_run(
    array $active_rules,
    array $rule_counts,
    array $settings,
    array $post_types,
    array $post_statuses,
    ?int $max_age_days,
    int $max_links
): array {
    $existing_job = get_public_auto_link_batch_progress();
    if (is_array($existing_job) && ($existing_job['status'] ?? '') === 'processing') {
        return [
            'already_running' => true,
            'job' => $existing_job,
            'message' => __('A background auto-linking run is already in progress.', 'prorank-seo'),
        ];
    }

    $post_ids = get_auto_link_scope_post_ids($post_types, $post_statuses, $max_age_days);

    if (empty($post_ids)) {
        return [
            'already_running' => false,
            'job' => null,
            'message' => __('No eligible posts were found for auto-linking.', 'prorank-seo'),
        ];
    }

    $job_id = wp_generate_uuid4();
    $target_inbound_counts = build_auto_link_target_inbound_counts($active_rules, $post_types, $post_statuses, $max_age_days);
    $progress = [
        'job_id' => $job_id,
        'status' => 'processing',
        'message' => sprintf(
            __('Queued background auto-linking for %d eligible posts.', 'prorank-seo'),
            count($post_ids)
        ),
        'total_posts' => count($post_ids),
        'processed_posts' => 0,
        'posts_updated' => 0,
        'links_created' => 0,
        'error_count' => 0,
        'errors' => [],
        'current_offset' => 0,
        'batch_size' => AUTO_LINK_BATCH_SIZE,
        'post_ids' => $post_ids,
        'active_rules' => array_values($active_rules),
        'rule_counts' => $rule_counts,
        'target_inbound_counts' => $target_inbound_counts,
        'settings' => $settings,
        'max_links' => $max_links,
        'started_at' => current_time('mysql'),
        'updated_at' => current_time('mysql'),
        'completed_at' => null,
    ];

    wp_clear_scheduled_hook(AUTO_LINK_BATCH_HOOK);
    update_option(AUTO_LINK_BATCH_PROGRESS_OPTION, $progress);

    process_auto_link_batch_event($job_id);

    return [
        'already_running' => false,
        'job' => get_public_auto_link_batch_progress(),
        'message' => __('Auto-linking started. It will continue in background batches until all eligible posts are processed.', 'prorank-seo'),
    ];
}

/**
 * Cancel the active auto-link background run.
 *
 * @param WP_REST_Request $request Request object.
 * @return WP_REST_Response
 */
function cancel_auto_linking(WP_REST_Request $request): WP_REST_Response {
    $progress = get_auto_link_batch_progress_state();

    if (($progress['status'] ?? 'idle') !== 'processing' || empty($progress['job_id'])) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('There is no active auto-linking run to cancel.', 'prorank-seo'),
            'data' => [
                'job' => get_public_auto_link_batch_progress(),
            ],
        ]);
    }

    wp_clear_scheduled_hook(AUTO_LINK_BATCH_HOOK);
    $progress['status'] = 'cancelled';
    $progress['message'] = sprintf(
        __('Auto-linking was cancelled after processing %1$d of %2$d eligible posts.', 'prorank-seo'),
        (int) ($progress['processed_posts'] ?? 0),
        (int) ($progress['total_posts'] ?? 0)
    );
    $progress['completed_at'] = current_time('mysql');
    $progress['updated_at'] = $progress['completed_at'];
    update_option(AUTO_LINK_BATCH_PROGRESS_OPTION, $progress);
    delete_transient(AUTO_LINK_BATCH_LOCK_TRANSIENT);

    return rest_ensure_response([
        'success' => true,
        'message' => (string) $progress['message'],
        'data' => [
            'job' => get_public_auto_link_batch_progress(),
        ],
    ]);
}

/**
 * Resume a cancelled auto-link background run.
 *
 * @param WP_REST_Request $request Request object.
 * @return WP_REST_Response
 */
function resume_auto_linking(WP_REST_Request $request): WP_REST_Response {
    $progress = get_auto_link_batch_progress_state();

    if (($progress['status'] ?? 'idle') === 'processing') {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Auto-linking is already running.', 'prorank-seo'),
            'data' => [
                'job' => get_public_auto_link_batch_progress(),
            ],
        ]);
    }

    if (($progress['status'] ?? 'idle') !== 'cancelled' || empty($progress['job_id'])) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('There is no cancelled auto-linking run to resume.', 'prorank-seo'),
            'data' => [
                'job' => get_public_auto_link_batch_progress(),
            ],
        ]);
    }

    if ((int) ($progress['processed_posts'] ?? 0) >= (int) ($progress['total_posts'] ?? 0)) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('This auto-linking run is already complete. Clear it or start a new run.', 'prorank-seo'),
            'data' => [
                'job' => get_public_auto_link_batch_progress(),
            ],
        ]);
    }

    $progress['status'] = 'processing';
    $progress['message'] = sprintf(
        __('Resumed background auto-linking at %1$d of %2$d eligible posts.', 'prorank-seo'),
        (int) ($progress['processed_posts'] ?? 0),
        (int) ($progress['total_posts'] ?? 0)
    );
    $progress['updated_at'] = current_time('mysql');
    $progress['completed_at'] = null;
    update_option(AUTO_LINK_BATCH_PROGRESS_OPTION, $progress);

    process_auto_link_batch_event((string) $progress['job_id']);

    return rest_ensure_response([
        'success' => true,
        'message' => __('Auto-linking resumed.', 'prorank-seo'),
        'data' => [
            'job' => get_public_auto_link_batch_progress(),
        ],
    ]);
}

/**
 * Clear completed or cancelled auto-link job state.
 *
 * @param WP_REST_Request $request Request object.
 * @return WP_REST_Response
 */
function clear_auto_linking_job(WP_REST_Request $request): WP_REST_Response {
    $progress = get_auto_link_batch_progress_state();

    if (($progress['status'] ?? 'idle') === 'processing') {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Cancel the active auto-linking run before clearing it.', 'prorank-seo'),
            'data' => [
                'job' => get_public_auto_link_batch_progress(),
            ],
        ]);
    }

    if (empty($progress['job_id'])) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('There is no saved auto-linking job to clear.', 'prorank-seo'),
            'data' => [
                'job' => null,
            ],
        ]);
    }

    wp_clear_scheduled_hook(AUTO_LINK_BATCH_HOOK);
    delete_transient(AUTO_LINK_BATCH_LOCK_TRANSIENT);
    delete_option(AUTO_LINK_BATCH_PROGRESS_OPTION);

    return rest_ensure_response([
        'success' => true,
        'message' => __('Saved auto-linking job state cleared.', 'prorank-seo'),
        'data' => [
            'job' => null,
        ],
    ]);
}

function run_auto_linking(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;

    $dry_run = rest_sanitize_boolean($request->get_param('dry_run'));
    $max_links = (int) ($request->get_param('max_links') ?? 0);

    $rules = get_option('prorank_auto_link_rules', []);
    $settings = get_internal_linking_runtime_settings();

    if (isset($settings['enabled']) && !$settings['enabled']) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Internal linking is disabled in settings.', 'prorank-seo'),
        ]);
    }

    if (empty($settings['auto_linking'])) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Auto-linking is disabled in settings.', 'prorank-seo'),
        ]);
    }

    $configured_post_types = get_internal_linking_scoped_post_types($settings);
    $requested_post_types = $request->get_param('post_types');
    if (is_array($requested_post_types) && !empty($requested_post_types)) {
        $requested_post_types = array_values(array_filter(array_map('sanitize_key', $requested_post_types)));
        $post_types = array_values(array_intersect($configured_post_types, $requested_post_types));
    } else {
        $post_types = $configured_post_types;
    }

    if (empty($post_types)) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('No eligible post types are enabled for auto-linking.', 'prorank-seo'),
        ]);
    }

    $post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);

    $configured_max_links = (int) ($settings['max_links_per_post'] ?? 3);
    if ($configured_max_links <= 0) {
        $configured_max_links = 3;
    }
    if ($max_links <= 0) {
        $max_links = $configured_max_links;
    }
    $max_outbound_links = (int) ($settings['max_outbound_links'] ?? 0);
    if ($max_outbound_links > 0) {
        $max_links = min($max_links, $max_outbound_links);
    }
    $max_links = max(1, $max_links);

    $resolved_rules = resolve_active_auto_link_rules($rules);
    $active_rules = $resolved_rules['rules'];
    $rule_counts = $resolved_rules['counts'];
    $target_inbound_counts = build_auto_link_target_inbound_counts($active_rules, $post_types, $post_statuses, $max_age_days);

    if (empty($active_rules)) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Add and enable at least one auto-link rule before running this tool.', 'prorank-seo'),
        ]);
    }

    if (!$dry_run) {
        $batch_run = start_auto_link_batch_run(
            $active_rules,
            $rule_counts,
            $settings,
            $post_types,
            $post_statuses,
            $max_age_days,
            $max_links
        );

        return rest_ensure_response([
            'success' => true,
            'message' => (string) ($batch_run['message'] ?? ''),
            'data' => [
                'dry_run' => false,
                'job' => $batch_run['job'] ?? null,
                'already_running' => !empty($batch_run['already_running']),
            ],
        ]);
    }

    $preview = [];
    $preview_post_ids = get_auto_link_scope_post_ids($post_types, $post_statuses, $max_age_days, AUTO_LINK_PREVIEW_LIMIT);

    foreach ($preview_post_ids as $post_id) {
        $post = get_post($post_id);
        if (!$post instanceof \WP_Post) {
            continue;
        }

        $preview_rule_counts = $rule_counts;
        $result = apply_auto_link_rules_to_post($post, $active_rules, $preview_rule_counts, $target_inbound_counts, $settings, $max_links);

        if (empty($result['changed'])) {
            continue;
        }

        $rule_counts = $preview_rule_counts;
        $preview[] = [
            'post_id' => $post->ID,
            'post_title' => $post->post_title,
            'post_type' => $post->post_type,
            'changes' => $result['changes'],
            'total_links' => $result['links_in_post'],
            'preview_content' => $result['preview_content'],
        ];
    }

    $scope = build_auto_link_scope_query_parts($post_types, $post_statuses, $max_age_days);

    if (!empty($settings['ai_suggestions']) && class_exists('\ProRank\SEO\Modules\Content\InternalLinkingEnhanced')) {
        $enhanced = new \ProRank\SEO\Modules\Content\InternalLinkingEnhanced();
        $orphans = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT ID, post_content FROM {$wpdb->posts}
                WHERE {$scope['sql']}
                AND ID NOT IN (
                    SELECT DISTINCT ID FROM {$wpdb->posts} p2
                    WHERE INSTR(p2.post_content, p2.guid) > 0
                )
                LIMIT 10",
                ...$scope['args']
            ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        );

        foreach ($orphans as $orphan) {
            if (count($preview) >= 10) {
                break;
            }

            $suggestions = $enhanced->get_enhanced_suggestions($orphan->ID, $orphan->post_content, [
                'limit' => 3,
            ]);

            if (empty($suggestions)) {
                continue;
            }

            $preview[] = [
                'post_id' => $orphan->ID,
                'post_title' => get_the_title($orphan->ID),
                'post_type' => get_post_type($orphan->ID),
                'ai_suggestions' => array_slice($suggestions, 0, 3),
                'suggestion_type' => 'ai_enhanced',
            ];
        }
    }

    return rest_ensure_response([
        'success' => true,
        'data' => [
            'links_created' => 0,
            'posts_processed' => 0,
            'preview' => $preview,
            'dry_run' => true,
        ],
    ]);
}

/**
 * Generate auto-link rules from GSC keywords
 */
function generate_gsc_auto_link_rules(WP_REST_Request $request): WP_REST_Response {
    $settings = get_internal_linking_settings();
    
    if (empty($settings['gsc_integration'])) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('GSC integration is not enabled', 'prorank-seo'),
        ]);
    }
    
    $min_impressions = (int) ($request->get_param('min_impressions') ?? 100);
    $min_ctr = (float) ($request->get_param('min_ctr') ?? 0.01);
    $limit = (int) ($request->get_param('limit') ?? 20);
    
    $generated_rules = [];
    
    // Use InternalLinkingEnhanced to get GSC data
    if (class_exists('\ProRank\SEO\Modules\Content\InternalLinkingEnhanced')) {
        $enhanced = new \ProRank\SEO\Modules\Content\InternalLinkingEnhanced();
        
        global $wpdb;
        
        // Get top performing pages from GSC
        $posts = $wpdb->get_results($wpdb->prepare(
            "SELECT ID, post_title, post_content 
            FROM {$wpdb->posts} 
            WHERE post_status = 'publish' 
            AND post_type IN ('post', 'page')
            ORDER BY post_date DESC
            LIMIT %d",
            $limit
        ));
        
        foreach ($posts as $post) {
            // Get recommended keywords for this post
            $keywords_data = $enhanced->get_recommended_keywords($post->ID);
            
            if (!empty($keywords_data['sources']['gsc'])) {
                // Use top GSC keywords to create auto-link rules
                $top_keywords = array_slice($keywords_data['sources']['gsc'], 0, 3);
                
                foreach ($top_keywords as $keyword) {
                    // Only create rule if keyword is meaningful (3+ characters)
                    if (strlen($keyword) >= 3) {
                        $rule = [
                            'id' => 'gsc_' . uniqid(),
                            'keyword' => $keyword,
                            'target_url' => get_permalink($post->ID),
                            'target_title' => $post->post_title,
                            'case_sensitive' => false,
                            'whole_word' => true,
                            'max_links_per_post' => 1,
                            'max_total_links' => 10,
                            'exclude_posts' => (string) $post->ID, // Don't link to self
                            'priority' => 5,
                            'enabled' => false, // Start disabled for review
                            'source' => 'gsc',
                            'created' => current_time('mysql'),
                        ];
                        
                        $generated_rules[] = $rule;
                    }
                }
            }
            
            // Also check focus keywords
            if (!empty($keywords_data['sources']['focus'])) {
                foreach ($keywords_data['sources']['focus'] as $keyword) {
                    if (strlen($keyword) >= 3) {
                        $rule = [
                            'id' => 'focus_' . uniqid(),
                            'keyword' => $keyword,
                            'target_url' => get_permalink($post->ID),
                            'target_title' => $post->post_title,
                            'case_sensitive' => false,
                            'whole_word' => false, // Focus keywords can be partial matches
                            'max_links_per_post' => 2,
                            'max_total_links' => 20,
                            'exclude_posts' => (string) $post->ID,
                            'priority' => 8, // Higher priority for focus keywords
                            'enabled' => false,
                            'source' => 'focus',
                            'created' => current_time('mysql'),
                        ];
                        
                        $generated_rules[] = $rule;
                    }
                }
            }
        }
    }
    
    // Save generated rules if requested
    if ($request->get_param('save') === true && !empty($generated_rules)) {
        $existing_rules = get_option('prorank_auto_link_rules', []);
        $merged_rules = array_merge($existing_rules, $generated_rules);
        update_option('prorank_auto_link_rules', $merged_rules);
    }
    
    return rest_ensure_response([
        'success' => true,
        'data' => [
            'rules_generated' => count($generated_rules),
            'rules' => $generated_rules,
            'message' => sprintf(
/* translators: %1$: value */
                __('%1$d auto-link rules generated from GSC and focus keywords', 'prorank-seo'),
                count($generated_rules)
            ),
        ],
    ]);
}

/**
 * Get orphaned posts in the format the new frontend expects
 * Optimized with pagination to prevent memory exhaustion on large sites
 */
function get_orphaned_posts_new(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $settings = get_internal_linking_runtime_settings();
    $post_types = get_internal_linking_scoped_post_types($settings);
    $post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);

    // Pagination parameters
    $page = max(1, (int) $request->get_param('page') ?: 1);
    $per_page = min(100, max(10, (int) $request->get_param('per_page') ?: 50));
    $offset = ($page - 1) * $per_page;

    $status_placeholders = implode(',', array_fill(0, count($post_statuses), '%s'));
    $type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $scope_sql = "post_status IN ({$status_placeholders}) AND post_type IN ({$type_placeholders})";
    $scope_args = array_merge($post_statuses, $post_types);
    if ($max_age_days !== null) {
        $scope_sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $scope_args[] = $max_age_days;
    }

    // Get total count for pagination info
    $total_posts = (int) $wpdb->get_var(
        $wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->posts}
            WHERE {$scope_sql}",
            ...$scope_args
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    // Get paginated posts
    $all_posts = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT ID, post_title, post_name, post_date, post_type, post_content
            FROM {$wpdb->posts}
            WHERE {$scope_sql}
            ORDER BY post_date DESC
            LIMIT %d OFFSET %d",
            ...array_merge($scope_args, [$per_page, $offset])
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    $candidate_source_posts = get_posts([
        'post_type'              => $post_types,
        'post_status'            => $post_statuses,
        'numberposts'            => -1,
        'no_found_rows'          => true,
        'update_post_meta_cache' => false,
        'update_post_term_cache' => false,
        'suppress_filters'       => false,
        'date_query'             => $max_age_days !== null ? [[
            'after'     => sprintf('%d days ago', max(1, (int) $max_age_days)),
            'inclusive' => true,
        ]] : [],
    ]);

    $orphaned = [];

    foreach ($all_posts as $post) {
        if (!$post instanceof \WP_Post || is_orphan_detection_exempt_post($post)) {
            continue;
        }

        $permalink = (string) get_permalink($post->ID);
        $has_inbound = false;

        foreach ($candidate_source_posts as $source_post) {
            if (!$source_post instanceof \WP_Post || !can_use_post_as_orphan_source($source_post, (int) $post->ID)) {
                continue;
            }

            if (source_post_already_links_to_target($source_post, (int) $post->ID, $permalink)) {
                $has_inbound = true;
                break;
            }
        }

        if (!$has_inbound) {
            $full_post = get_post((int) $post->ID);
            $combined_content = get_post_linking_content((int) $post->ID, (string) $post->post_content);
            $word_count = str_word_count(wp_strip_all_tags($combined_content));
            $days_old = round((time() - strtotime($post->post_date)) / 86400);

            // Determine priority based on age and word count
            $priority = 'low';
            if ($days_old < 30 && $word_count > 500) {
                $priority = 'high';
            } elseif ($days_old < 90 || $word_count > 300) {
                $priority = 'medium';
            }

            $orphaned[] = [
                'id' => $post->ID,
                'title' => $post->post_title,
                'url' => $permalink,
                'type' => $post->post_type,
                'date' => $post->post_date,
                'daysOld' => $days_old,
                'wordCount' => $word_count,
                'inboundLinks' => 0,
                'outboundLinks' => $full_post instanceof \WP_Post ? count(BuilderContentSupport::extract_all_links((int) $full_post->ID, $full_post)) : 0,
                'priority' => $priority,
            ];
        }
    }

    $total_pages = ceil($total_posts / $per_page);

    return rest_ensure_response([
        'orphans' => $orphaned,
        'total' => count($orphaned),
        'pagination' => [
            'page' => $page,
            'per_page' => $per_page,
            'total_posts' => $total_posts,
            'total_pages' => $total_pages,
            'has_more' => $page < $total_pages,
        ],
    ]);
}

/**
 * Get inbound link opportunities
 */
function get_inbound_opportunities(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $settings = get_internal_linking_runtime_settings();
    $scoped_post_types = get_internal_linking_scoped_post_types($settings);
    $scoped_post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);

    $target_post_id = (int) ($request->get_param('target_post_id') ?: $request->get_param('post_id') ?: 0);

    // Modern shape: build inbound opportunities for one target post by scanning published content.
    if ($target_post_id > 0) {
        $target_post = get_post($target_post_id);
        if (
            !$target_post
            || !in_array((string) $target_post->post_status, $scoped_post_statuses, true)
            || !in_array((string) $target_post->post_type, $scoped_post_types, true)
        ) {
            return rest_ensure_response([
                'success' => false,
                'message' => __('Target post not found', 'prorank-seo'),
                'data' => [
                    'opportunities' => [],
                    'total' => 0,
                ],
            ]);
        }

        $target_url = (string) get_permalink($target_post_id);
        $target_path = normalize_internal_link_path((string) wp_parse_url($target_url, PHP_URL_PATH));
        $site_host = (string) wp_parse_url(home_url(), PHP_URL_HOST);

        $normalize_text = static function(string $value): string {
            $value = wp_strip_all_tags($value);
            $value = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $value);
            $value = trim((string) $value);
            if ($value === '') {
                return '';
            }
            if (function_exists('mb_strtolower')) {
                return (string) mb_strtolower($value);
            }
            return strtolower($value);
        };

        $stop_words = [
            'the', 'is', 'at', 'which', 'on', 'a', 'an', 'as', 'are', 'was', 'were', 'and',
            'for', 'with', 'from', 'that', 'this', 'your', 'you', 'our', 'their', 'into',
            'about', 'after', 'before', 'over', 'under', 'between', 'within',
            // Exclude common placeholder latin text from demo/starter content.
            'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur', 'adipiscing', 'elit',
            'sed', 'eiusmod', 'tempor', 'incididunt', 'labore', 'dolore', 'magna', 'aliqua',
        ];

        $is_candidate_token = static function($token) use ($stop_words): bool {
            if (!is_string($token)) {
                return false;
            }

            $token = trim($token);
            if ($token === '' || strlen($token) < 3) {
                return false;
            }

            if (in_array($token, $stop_words, true)) {
                return false;
            }

            // Skip mostly numeric tokens to reduce noisy anchors.
            return preg_match('/[a-z]/i', $token) === 1;
        };

        $title_tokens = array_values(array_filter(
            preg_split('/\s+/u', $normalize_text((string) $target_post->post_title)),
            $is_candidate_token
        ));

        $slug_tokens = array_values(array_filter(
            preg_split('/\s+/u', $normalize_text(str_replace('-', ' ', (string) $target_post->post_name))),
            $is_candidate_token
        ));

        $target_content = get_post_linking_content((int) $target_post_id, (string) $target_post->post_content);
        $target_content_tokens = array_values(array_filter(
            preg_split('/\s+/u', $normalize_text((string) $target_content)),
            static function($token) use ($is_candidate_token) {
                return $is_candidate_token($token) && strlen($token) >= 4;
            }
        ));

        $content_token_frequency = [];
        foreach ($target_content_tokens as $token) {
            $content_token_frequency[$token] = (int) ($content_token_frequency[$token] ?? 0) + 1;
        }
        arsort($content_token_frequency);
        $content_keywords = array_slice(
            array_keys(array_filter($content_token_frequency, static function($count) {
                return (int) $count >= 2;
            })),
            0,
            30
        );

        $primary_keywords = [];
        $token_count = count($title_tokens);
        for ($size = 3; $size >= 1; $size--) {
            if ($token_count < $size) {
                continue;
            }
            for ($i = 0; $i <= $token_count - $size; $i++) {
                $phrase = implode(' ', array_slice($title_tokens, $i, $size));
                if ($phrase !== '') {
                    $primary_keywords[] = $phrase;
                }
            }
        }
        $slug_token_count = count($slug_tokens);
        for ($size = 2; $size >= 1; $size--) {
            if ($slug_token_count < $size) {
                continue;
            }
            for ($i = 0; $i <= $slug_token_count - $size; $i++) {
                $phrase = implode(' ', array_slice($slug_tokens, $i, $size));
                if ($phrase !== '') {
                    $primary_keywords[] = $phrase;
                }
            }
        }

        $primary_keywords = array_values(array_unique(array_filter($primary_keywords, static function($keyword) {
            return is_string($keyword) && trim($keyword) !== '';
        })));
        usort($primary_keywords, static function($a, $b) {
            return strlen($b) <=> strlen($a);
        });

        $fallback_keywords = [];
        foreach ($content_keywords as $keyword) {
            $fallback_keywords[] = $keyword;
        }
        $fallback_keywords = array_values(array_unique(array_filter($fallback_keywords, static function($keyword) use ($primary_keywords) {
            return is_string($keyword) && trim($keyword) !== '' && !in_array($keyword, $primary_keywords, true);
        })));

        $keywords = array_merge($primary_keywords, $fallback_keywords);
        if (empty($keywords)) {
            return rest_ensure_response([
                'success' => true,
                'data' => [
                    'opportunities' => [],
                    'total' => 0,
                    'target_id' => $target_post_id,
                ],
            ]);
        }
        $primary_keyword_map = array_fill_keys($primary_keywords, true);

        $source_status_placeholders = implode(',', array_fill(0, count($scoped_post_statuses), '%s'));
        $source_type_placeholders = implode(',', array_fill(0, count($scoped_post_types), '%s'));
        $source_where_sql = "post_status IN ({$source_status_placeholders}) AND post_type IN ({$source_type_placeholders}) AND ID != %d";
        $source_args = array_merge($scoped_post_statuses, $scoped_post_types, [(int) $target_post_id]);
        if ($max_age_days !== null) {
            $source_where_sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
            $source_args[] = $max_age_days;
        }
        $source_args[] = 250;
        $source_posts = $wpdb->get_results($wpdb->prepare(
            "SELECT ID, post_title, post_content
            FROM {$wpdb->posts}
            WHERE {$source_where_sql}
            ORDER BY post_date DESC
            LIMIT %d",
            ...$source_args
        )); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

        $opportunities = [];
        $used_anchors = [];

        foreach ($source_posts as $source_post) {
            $source_content = get_post_linking_content((int) $source_post->ID, (string) $source_post->post_content);
            $source_plain = wp_strip_all_tags((string) $source_content);
            if (trim($source_plain) === '') {
                continue;
            }

            // Skip sources already linking to target.
            $already_linking = false;
            $source_urls = get_post_linking_urls((int) $source_post->ID, (string) $source_content);
            foreach ($source_urls as $raw_url) {
                $normalized_url = normalize_linking_url((string) $raw_url);
                if ($normalized_url === '' || !is_internal_linking_url($normalized_url, $site_host)) {
                    continue;
                }
                $resolved_path = normalize_internal_link_path((string) wp_parse_url($normalized_url, PHP_URL_PATH));
                if ($resolved_path === $target_path) {
                    $already_linking = true;
                    break;
                }
            }
            if ($already_linking) {
                continue;
            }

            $source_normalized = $normalize_text($source_plain);
            if ($source_normalized === '') {
                continue;
            }

            $best_anchor = '';
            $best_position = PHP_INT_MAX;
            $best_matches = 0;
            $best_priority = 0;

            foreach ($keywords as $keyword) {
                $needle = $normalize_text($keyword);
                if ($needle === '' || strpos($source_normalized, $needle) === false) {
                    continue;
                }

                $matches = 0;
                preg_match_all('/\b' . preg_quote($needle, '/') . '\b/u', $source_normalized, $match_results);
                if (!empty($match_results[0])) {
                    $matches = count($match_results[0]);
                }

                $position = strpos($source_normalized, $needle);
                if ($position === false) {
                    continue;
                }

                $keyword_priority = isset($primary_keyword_map[$needle]) ? 2 : 1;

                if (
                    $best_anchor === '' ||
                    $keyword_priority > $best_priority ||
                    (
                        $keyword_priority === $best_priority &&
                        (
                            strlen($needle) > strlen($best_anchor) ||
                            ($matches > $best_matches && strlen($needle) === strlen($best_anchor)) ||
                            ($matches === $best_matches && $position < $best_position)
                        )
                    )
                ) {
                    $best_anchor = $needle;
                    $best_position = (int) $position;
                    $best_matches = (int) $matches;
                    $best_priority = $keyword_priority;
                }
            }

            if ($best_anchor === '') {
                continue;
            }

            if (!\ProRank\SEO\Modules\Content\InternalLinking::is_anchor_valid_for_content($best_anchor, $source_plain)) {
                continue;
            }

            $anchor_key = trim(preg_replace('/\s+/u', ' ', $best_anchor));
            if ($anchor_key === '' || isset($used_anchors[$anchor_key])) {
                continue;
            }

            $used_anchors[$anchor_key] = true;

            $context = '';
            if ($best_position !== PHP_INT_MAX) {
                $context_start = max(0, $best_position - 60);
                $context = mb_substr($source_plain, $context_start, 180);
                $context = trim(preg_replace('/\s+/u', ' ', (string) $context));
            }

            $relevance = 50;
            $relevance += min(30, strlen($best_anchor) * 2);
            $relevance += min(20, $best_matches * 4);
            if ($best_position < 200) {
                $relevance += 5;
            }

            $opportunities[] = [
                'id' => 'inbound-' . $source_post->ID . '-' . $target_post_id . '-' . md5($anchor_key),
                'source_id' => (int) $source_post->ID,
                'source_title' => (string) $source_post->post_title,
                'source_url' => (string) get_permalink((int) $source_post->ID),
                'target_id' => $target_post_id,
                'target_title' => (string) $target_post->post_title,
                'target_url' => $target_url,
                'anchor_text' => $anchor_key,
                'context' => $context,
                'relevance_score' => max(1, min(100, (int) $relevance)),
            ];
        }

        usort($opportunities, static function($a, $b) {
            return (int) ($b['relevance_score'] ?? 0) <=> (int) ($a['relevance_score'] ?? 0);
        });

        return rest_ensure_response([
            'success' => true,
            'data' => [
                'opportunities' => array_slice($opportunities, 0, 50),
                'total' => count($opportunities),
                'target_id' => $target_post_id,
            ],
        ]);
    }

    // Legacy fallback for pages that still expect aggregate opportunities.
    $legacy_status_placeholders = implode(',', array_fill(0, count($scoped_post_statuses), '%s'));
    $legacy_type_placeholders = implode(',', array_fill(0, count($scoped_post_types), '%s'));
    $legacy_where_sql = "post_status IN ({$legacy_status_placeholders}) AND post_type IN ({$legacy_type_placeholders})";
    $legacy_args = array_merge($scoped_post_statuses, $scoped_post_types);
    if ($max_age_days !== null) {
        $legacy_where_sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $legacy_args[] = $max_age_days;
    }
    $legacy_args[] = 100;
    $posts = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT ID, post_title, post_name, post_date, post_type, post_content
            FROM {$wpdb->posts}
            WHERE {$legacy_where_sql}
            ORDER BY post_date DESC
            LIMIT %d",
            ...$legacy_args
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    $opportunities = [];
    foreach ($posts as $post) {
        $permalink = get_permalink($post->ID);
        $word_count = str_word_count(wp_strip_all_tags($post->post_content));
        $legacy_inbound_query = "SELECT COUNT(*) FROM {$wpdb->posts}
            WHERE post_status IN ({$legacy_status_placeholders})
            AND post_type IN ({$legacy_type_placeholders})
            AND ID != %d
            AND post_content LIKE %s";
        $legacy_inbound_args = array_merge(
            $scoped_post_statuses,
            $scoped_post_types,
            [(int) $post->ID, '%' . $wpdb->esc_like($permalink) . '%']
        );
        if ($max_age_days !== null) {
            $legacy_inbound_query .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
            $legacy_inbound_args[] = $max_age_days;
        }
        $inbound_count = (int) $wpdb->get_var($wpdb->prepare($legacy_inbound_query, ...$legacy_inbound_args)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

        if ($inbound_count < 3 && $word_count > 200) {
            $opportunities[] = [
                'id' => $post->ID,
                'title' => $post->post_title,
                'url' => $permalink,
                'type' => $post->post_type,
                'currentInbound' => $inbound_count,
                'wordCount' => $word_count,
                'potentialSources' => [],
                'opportunityScore' => max(0, min(100, (100 - ($inbound_count * 20)) * ($word_count / 500))),
            ];
        }
    }

    usort($opportunities, static function($a, $b) {
        return (float) ($b['opportunityScore'] ?? 0) <=> (float) ($a['opportunityScore'] ?? 0);
    });

    return rest_ensure_response([
        'opportunities' => array_slice($opportunities, 0, 50),
        'total' => count($opportunities),
    ]);
}

/**
 * Scan for inbound link opportunities
 */
function scan_inbound_opportunities(WP_REST_Request $request): WP_REST_Response {
    // Trigger a scan and return immediately
    update_option('prorank_inbound_scan_status', 'running');
    update_option('prorank_inbound_scan_progress', 0);
    update_option('prorank_last_inbound_scan', current_time('mysql'));
    
    // In production, this would trigger a background job
    // For now, we'll just return success
    
    return rest_ensure_response([
        'success' => true,
        'message' => 'Scan started successfully',
    ]);
}

/**
 * Get dashboard data for internal linking
 */
function get_dashboard_data(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $settings = get_internal_linking_runtime_settings();

    $scan_history = get_internal_linking_scan_history(12);
    $scan_trends = build_internal_linking_scan_trends($scan_history);
    $recent_changes = build_internal_linking_recent_changes($scan_trends);
    $latest_scan_snapshot = $scan_history[0] ?? null;
    $last_scan = is_array($latest_scan_snapshot)
        ? (string) ($latest_scan_snapshot['scanned_at'] ?? '')
        : (string) get_option('prorank_last_link_scan', null);

    $empty_dashboard_payload = [
        'total_posts' => 0,
        'total_pages' => 0,
        'total_internal_links' => 0,
        'total_external_links' => 0,
        'orphan_count' => 0,
        'broken_links' => 0,
        'opportunities' => 0,
        'avg_links' => 0,
        'posts_without_links' => 0,
        'link_coverage' => 0,
        'external_percentage' => 0,
        'anchor_score' => 0,
        'top_domains' => [],
        'link_clicks' => 0,
        'link_clicks_change' => 0,
        'last_scan' => $last_scan ?: null,
        'scan_history' => $scan_history,
        'scan_trends' => $scan_trends,
        'recent_changes' => $recent_changes,
        'link_distribution' => [],
    ];

    if (empty($settings['enable_link_analysis'])) {
        return rest_ensure_response([
            'success' => true,
            'data' => $empty_dashboard_payload,
            'message' => __('Links Report analytics is disabled in Internal Linking settings.', 'prorank-seo'),
        ]);
    }

    $post_types = get_internal_linking_scoped_post_types($settings);
    $post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);

    $status_placeholders = implode(',', array_fill(0, count($post_statuses), '%s'));
    $type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $scope_sql = "post_status IN ({$status_placeholders}) AND post_type IN ({$type_placeholders})";
    $scope_args = array_merge($post_statuses, $post_types);
    if ($max_age_days !== null) {
        $scope_sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $scope_args[] = $max_age_days;
    }

    $page_scope_args = $scope_args;
    $page_scope_sql = $scope_sql . " AND post_type = 'page'";
    $total_pages = (int) $wpdb->get_var(
        $wpdb->prepare(
            "SELECT COUNT(*) FROM {$wpdb->posts} WHERE {$page_scope_sql}",
            ...$page_scope_args
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    $total_content = 0;
    $total_internal_links = 0;
    $total_external_links = 0;
    $posts_with_links = 0;
    $orphan_count = 0;
    $broken_links = 0;
    $avg_links = 0;
    $posts_without_links = 0;
    $link_coverage = 0;
    $external_percentage = 0;
    $anchor_score = 0;
    $top_domains = [];

    if (is_array($latest_scan_snapshot)) {
        // Use the latest completed scan snapshot instead of crawling every post on page load.
        $total_content = max(0, (int) ($latest_scan_snapshot['total_posts'] ?? 0));
        $posts_with_links = max(0, (int) ($latest_scan_snapshot['posts_with_links'] ?? 0));
        $total_internal_links = max(0, (int) ($latest_scan_snapshot['total_internal_links'] ?? 0));
        $total_external_links = max(0, (int) ($latest_scan_snapshot['total_external_links'] ?? 0));
        $orphan_count = max(0, (int) ($latest_scan_snapshot['orphan_count'] ?? 0));
        $broken_links = max(0, (int) ($latest_scan_snapshot['broken_links'] ?? 0));
        $link_coverage = round((float) ($latest_scan_snapshot['link_coverage'] ?? 0), 2);
        $avg_links = round((float) ($latest_scan_snapshot['avg_links'] ?? 0), 2);
        $external_percentage = round((float) ($latest_scan_snapshot['external_percentage'] ?? 0), 2);
        $posts_without_links = max(0, $total_content - $posts_with_links);
    } else {
        // First-load fallback: return cheap scoped content counts and prompt the user to run a scan.
        $total_content = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->posts} WHERE {$scope_sql}",
                ...$scope_args
            ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        );
    }

    // Get real link clicks data from tracker only when CTR tracking is enabled.
    $link_clicks = 0;
    $link_clicks_change = 0;
    $ctr_tracking_enabled = !empty($settings['track_ctr']) && !empty($settings['track_clicks']);

    if ($ctr_tracking_enabled) {
        $link_clicks = (int) get_option('prorank_link_clicks', 0);
        $link_clicks_change = (int) get_option('prorank_link_clicks_change', 0);

        if (class_exists('\\ProRank\\SEO\\Core\\LinkClickTracker')) {
            $tracker = new \ProRank\SEO\Core\LinkClickTracker();
            $click_stats = $tracker->get_click_stats(7); // Last 7 days
            if ($click_stats['total_clicks'] > 0) {
                $link_clicks = $click_stats['total_clicks'];
                $link_clicks_change = $click_stats['trend'];
            }
        }
    }
    
    return rest_ensure_response([
        'success' => true,
        'data' => [
            'total_posts' => $total_content,
            'total_pages' => $total_pages,
            'total_internal_links' => $total_internal_links,
            'total_external_links' => $total_external_links,
            'orphan_count' => $orphan_count,
            'broken_links' => $broken_links,
            'opportunities' => $orphan_count + $posts_without_links,
            'avg_links' => round($avg_links, 1),
            'posts_without_links' => $posts_without_links,
            'link_coverage' => round($link_coverage, 2),
            'external_percentage' => round($external_percentage, 0),
            'anchor_score' => round($anchor_score, 0),
            'top_domains' => $top_domains,
            'link_clicks' => $link_clicks,
            'link_clicks_change' => $link_clicks_change,
            'last_scan' => $last_scan,
            'scan_history' => $scan_history,
            'scan_trends' => $scan_trends,
            'recent_changes' => $recent_changes,
            'link_distribution' => [],
        ],
        'message' => is_array($latest_scan_snapshot)
            ? __('Dashboard data is based on the most recent completed scan.', 'prorank-seo')
            : __('Run a full internal-linking scan to populate dashboard metrics.', 'prorank-seo'),
    ]);
}

/**
 * Run link analysis
 */
function run_link_analysis(WP_REST_Request $request): WP_REST_Response {
    $settings = get_internal_linking_runtime_settings();
    if (empty($settings['enable_link_analysis'])) {
        return rest_ensure_response([
            'success' => true,
            'data' => [
                'suggestions' => [],
                'stats' => [
                    'internal' => 0,
                    'external' => 0,
                    'density' => 0,
                ],
            ],
            'message' => __('Links Report analytics is disabled in Internal Linking settings.', 'prorank-seo'),
        ]);
    }

    // Keep legacy status options for dashboard widgets that read these keys.
    update_option('prorank_link_analysis_status', 'running');
    update_option('prorank_link_analysis_started', current_time('mysql'));

    // Delegate to the modern controller so /linking/analyze returns real suggestions.
    try {
        if (class_exists(__NAMESPACE__ . '\\LinkingController')) {
            $controller = new LinkingController();
            $response = $controller->analyze_content($request);

            if ($response instanceof WP_REST_Response) {
                return $response;
            }
        }
    } catch (\Throwable $e) {
        prorank_log('[ProRank SEO] Internal linking analyze fallback error: ' . $e->getMessage());
    }

    // Safe fallback with expected payload shape.
    return rest_ensure_response([
        'success' => true,
        'data' => [
            'suggestions' => [],
            'stats' => [
                'internal' => 0,
                'external' => 0,
                'density' => 0,
            ],
        ],
        'message' => __('No matching internal linking suggestions were found for this content.', 'prorank-seo'),
    ]);
}

/**
 * Get full links report
 */
function get_full_report(WP_REST_Request $request): WP_REST_Response {
    $settings = get_internal_linking_runtime_settings();
    $last_scan = get_option('prorank_last_link_scan', null);

    if (empty($settings['enable_link_analysis'])) {
        return rest_ensure_response([
            'success' => true,
            'posts' => [],
            'stats' => [
                'total_posts' => 0,
                'total_internal' => 0,
                'total_external' => 0,
                'orphan_count' => 0,
                'broken_count' => 0,
            ],
            'last_scan' => $last_scan,
            'message' => __('Links Report analytics is disabled in Internal Linking settings.', 'prorank-seo'),
        ]);
    }

    $cached_report = get_internal_linking_full_report_cache();
    if (!empty($cached_report)) {
        return rest_ensure_response([
            'success' => true,
            'posts' => $cached_report['posts'],
            'stats' => $cached_report['stats'],
            'last_scan' => $cached_report['last_scan'] ?? $last_scan,
            'message' => __('Links Report data is based on the most recent completed scan.', 'prorank-seo'),
        ]);
    }

    $latest_snapshot = get_internal_linking_scan_history(1)[0] ?? null;

    return rest_ensure_response([
        'success' => true,
        'posts' => [],
        'stats' => build_internal_linking_full_report_stats_from_snapshot($latest_snapshot),
        'last_scan' => is_array($latest_snapshot)
            ? (string) ($latest_snapshot['scanned_at'] ?? $last_scan)
            : $last_scan,
        'message' => $last_scan
            ? __('Run a fresh full scan to populate the post-level links report.', 'prorank-seo')
            : __('Run a full link scan to populate the links report.', 'prorank-seo'),
    ]);
}

/**
 * Start full link scan
 */
function start_full_scan(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $settings = get_internal_linking_runtime_settings();

    if (empty($settings['enabled'])) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Internal linking is disabled in settings.', 'prorank-seo'),
        ]);
    }

    $post_types = get_internal_linking_scoped_post_types($settings);
    $post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);
    $status_placeholders = implode(',', array_fill(0, count($post_statuses), '%s'));
    $type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $scope_sql = "post_status IN ({$status_placeholders}) AND post_type IN ({$type_placeholders})";
    $scope_args = array_merge($post_statuses, $post_types);
    if ($max_age_days !== null) {
        $scope_sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $scope_args[] = $max_age_days;
    }

    $scan_timestamp = current_time('mysql');

    // Initialize scan progress
    update_option('prorank_link_scan_status', 'running');
    update_option('prorank_link_scan_progress', 0);
    update_option('prorank_link_scan_current', 0);
    update_option('prorank_link_scan_message', __('Starting link scan...', 'prorank-seo'));

    // Update last scan time
    update_option('prorank_last_link_scan', $scan_timestamp);
    update_option('prorank_last_full_scan', $scan_timestamp);

    // Get all scoped posts for scanning
    $posts = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT ID, post_title, post_type, post_date, post_content
            FROM {$wpdb->posts}
            WHERE {$scope_sql}",
            ...$scope_args
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    $total = count($posts);
    update_option('prorank_link_scan_total', $total);

    $site_host = (string) wp_parse_url(home_url(), PHP_URL_HOST);
    $path_map = [];
    foreach ($posts as $post) {
        $post_permalink = (string) get_permalink((int) $post->ID);
        $normalized_path = normalize_internal_link_path((string) wp_parse_url($post_permalink, PHP_URL_PATH));
        $path_map[$normalized_path] = (int) $post->ID;
    }

    $orphan_count = 0;
    $broken_count = 0;
    $suggestion_count = 0;
    $total_internal_links = 0;
    $total_external_links = 0;
    $posts_with_links = 0;
    $unique_internal_targets = [];
    $incoming_links = [];
    $outbound_internal_counts = [];
    $outbound_external_counts = [];
    $broken_links_by_post = [];
    $category_cache = [];
    $report_data = [];

    // First pass: collect all incoming links
    update_option('prorank_link_scan_message', __('Analyzing internal links...', 'prorank-seo'));
    $current = 0;

    foreach ($posts as $post) {
        $post_id = (int) $post->ID;
        $outbound_internal_counts[$post_id] = 0;
        $outbound_external_counts[$post_id] = 0;
        $broken_links_by_post[$post_id] = 0;

        $content = get_post_linking_content($post_id, (string) $post->post_content);
        $urls = get_post_linking_urls((int) $post->ID, $content);
        if (!empty($urls)) {
            $posts_with_links++;
        }

        foreach ($urls as $raw_url) {
            $url = normalize_linking_url((string) $raw_url);
            if ($url === '') {
                continue;
            }

            if (is_internal_linking_url($url, $site_host)) {
                $total_internal_links++;
                $outbound_internal_counts[$post_id]++;
                $target_path = normalize_internal_link_path((string) wp_parse_url($url, PHP_URL_PATH));
                $unique_internal_targets[$target_path] = true;

                $target_id = resolve_internal_post_id($url, $path_map);
                if ($target_id > 0) {
                    if (!isset($incoming_links[$target_id])) {
                        $incoming_links[$target_id] = 0;
                    }
                    $incoming_links[$target_id]++;
                } else {
                    $broken_count++;
                    $broken_links_by_post[$post_id]++;
                }
                continue;
            }

            if (str_starts_with($url, 'http')) {
                $total_external_links++;
                $outbound_external_counts[$post_id]++;
            }
        }
        $current++;
        if ($current % 10 === 0) {
            $progress = round(($current / $total) * 50); // First pass = 50%
            update_option('prorank_link_scan_current', $current);
            update_option('prorank_link_scan_progress', $progress);
        }
    }

    // Second pass: detect orphans
    update_option('prorank_link_scan_message', __('Detecting orphan pages...', 'prorank-seo'));
    $current = 0;

    foreach ($posts as $post) {
        $post_id = (int) $post->ID;
        $inbound_internal = (int) ($incoming_links[$post_id] ?? 0);
        if ($inbound_internal === 0) {
            $orphan_count++;
        }

        if (!isset($category_cache[$post_id])) {
            $category_cache[$post_id] = wp_get_post_categories($post_id);
        }

        $report_data[] = [
            'id' => $post_id,
            'title' => (string) $post->post_title,
            'url' => (string) get_permalink($post_id),
            'post_type' => (string) $post->post_type,
            'date' => wp_date('Y-m-d', strtotime((string) $post->post_date)),
            'categories' => $category_cache[$post_id],
            'inbound_internal' => $inbound_internal,
            'outbound_internal' => (int) ($outbound_internal_counts[$post_id] ?? 0),
            'outbound_external' => (int) ($outbound_external_counts[$post_id] ?? 0),
            'broken_links' => (int) ($broken_links_by_post[$post_id] ?? 0),
        ];

        $current++;
        if ($current % 10 === 0) {
            $progress = 50 + round(($current / $total) * 50); // Second pass = 50-100%
            update_option('prorank_link_scan_current', $current);
            update_option('prorank_link_scan_progress', $progress);
        }
    }

    $total_links = $total_internal_links + $total_external_links;
    $link_coverage = $total > 0 ? ($posts_with_links / $total) * 100 : 0;
    $avg_links = $posts_with_links > 0 ? ($total_links / $posts_with_links) : 0;
    $external_percentage = $total_links > 0 ? ($total_external_links / $total_links) * 100 : 0;

    // Update final stats
    update_option('prorank_orphaned_count', $orphan_count);
    update_option('prorank_broken_links_count', $broken_count);
    update_option('prorank_suggestions_count', $suggestion_count);

    if (!empty($settings['generate_reports'])) {
        store_internal_linking_scan_snapshot([
            'scanned_at' => $scan_timestamp,
            'method_version' => LINKING_SCAN_METHOD_VERSION,
            'total_posts' => $total,
            'total_internal_links' => $total_internal_links,
            'total_external_links' => $total_external_links,
            'posts_with_links' => $posts_with_links,
            'unique_internal_targets' => count($unique_internal_targets),
            'orphan_count' => $orphan_count,
            'broken_links' => $broken_count,
            'link_coverage' => round($link_coverage, 2),
            'avg_links' => round($avg_links, 2),
            'external_percentage' => round($external_percentage, 2),
        ]);

        store_internal_linking_full_report_cache([
            'generated_at' => current_time('mysql'),
            'last_scan' => $scan_timestamp,
            'posts' => $report_data,
            'stats' => [
                'total_posts' => $total,
                'total_internal' => $total_internal_links,
                'total_external' => $total_external_links,
                'orphan_count' => $orphan_count,
                'broken_count' => $broken_count,
            ],
        ]);
    } else {
        delete_option(LINKING_SCAN_HISTORY_OPTION);
        delete_option(LINKING_FULL_REPORT_OPTION);
    }

    // Mark scan as complete
    update_option('prorank_link_scan_current', $total);
    update_option('prorank_link_scan_status', 'complete');
    update_option('prorank_link_scan_progress', 100);
    update_option('prorank_link_scan_message', __('Scan complete!', 'prorank-seo'));

    return rest_ensure_response([
        'success' => true,
        'message' => 'Link scan completed successfully',
        'scan_id' => uniqid('scan_'),
        'stats' => [
            'orphan_count' => $orphan_count,
            'broken_links' => $broken_count,
            'total_posts' => $total,
            'total_internal_links' => $total_internal_links,
            'total_external_links' => $total_external_links,
            'posts_with_links' => $posts_with_links,
            'unique_internal_targets' => count($unique_internal_targets),
            'link_coverage' => round($link_coverage, 2),
        ],
    ]);
}

/**
 * Get broken links
 */
function get_broken_links(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $settings = get_internal_linking_runtime_settings();
    $last_scan = get_option('prorank_last_broken_scan', null);

    if (empty($settings['detect_broken_links'])) {
        return rest_ensure_response([
            'success' => true,
            'links' => [],
            'stats' => [
                'total_broken' => 0,
                'status_404' => 0,
                'status_403' => 0,
                'status_400' => 0,
                'status_500' => 0,
                'connection_failed' => 0,
                'invalid_links' => 0,
                'ignored' => 0,
                'high_confidence' => 0,
                'last_scan' => $last_scan,
            ],
            'message' => __('Broken link detection is disabled in Internal Linking settings.', 'prorank-seo'),
        ]);
    }

    $post_types = get_internal_linking_scoped_post_types($settings);
    $post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);
    $status_placeholders = implode(',', array_fill(0, count($post_statuses), '%s'));
    $type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $scope_sql = "p.post_status IN ({$status_placeholders}) AND p.post_type IN ({$type_placeholders})";
    $scope_args = array_merge($post_statuses, $post_types);
    if ($max_age_days !== null) {
        $scope_sql .= ' AND p.post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $scope_args[] = $max_age_days;
    }
    
    $table_name = $wpdb->prefix . 'prorank_broken_links';
    ensure_internal_linking_schema_ready();

    $stats = [
        'total_broken' => 0,
        'status_404' => 0,
        'status_403' => 0,
        'status_400' => 0,
        'status_500' => 0,
        'connection_failed' => 0,
        'invalid_links' => 0,
        'ignored' => 0,
        'high_confidence' => 0,
    ];

    $rows = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT bl.id, bl.post_id, bl.url, bl.anchor_text, bl.context, bl.status_code, bl.status, bl.confidence, bl.last_checked, bl.created_at,
                    p.post_title, p.post_type
             FROM {$table_name} bl
             INNER JOIN {$wpdb->posts} p ON p.ID = bl.post_id
             WHERE {$scope_sql}
               AND (
                    bl.status = 'ignored'
                    OR bl.status_code = 0
                    OR bl.status_code >= 400
                    OR bl.status_code IN (800, 925)
               )
             ORDER BY COALESCE(bl.last_checked, bl.created_at) DESC, bl.id DESC",
            ...$scope_args
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    $broken_links = [];
    $category_cache = [];

    foreach ($rows as $row) {
        $post_id = (int) $row->post_id;
        if (!isset($category_cache[$post_id])) {
            $category_cache[$post_id] = wp_get_post_categories($post_id);
        }

        $status_code = (int) ($row->status_code ?? 0);
        $status = (string) ($row->status ?? 'pending');

        if ($status !== 'ignored' && $status_code > 0 && $status_code < 400) {
            continue;
        }

        $broken_links[] = [
            'id' => (string) $row->id,
            'post_id' => $post_id,
            'post_title' => (string) ($row->post_title ?? ''),
            'post_url' => (string) get_permalink($post_id),
            'post_type' => (string) ($row->post_type ?? ''),
            'url' => (string) ($row->url ?? ''),
            'anchor_text' => (string) ($row->anchor_text ?? ''),
            'context' => (string) ($row->context ?? ''),
            'status_code' => $status_code,
            'status' => $status,
            'confidence' => (string) ($row->confidence ?? 'low'),
            'categories' => $category_cache[$post_id],
        ];

        if ($status === 'ignored') {
            $stats['ignored']++;
            continue;
        }

        $stats['total_broken']++;
        if ($status_code === 404) {
            $stats['status_404']++;
        } elseif ($status_code === 403) {
            $stats['status_403']++;
        } elseif ($status_code === 400) {
            $stats['status_400']++;
        } elseif ($status_code === 0) {
            $stats['connection_failed']++;
        } elseif ($status_code === 800 || $status_code === 925) {
            $stats['invalid_links']++;
        } elseif ($status_code >= 500 && $status_code < 600) {
            $stats['status_500']++;
        }

        if (($row->confidence ?? '') === 'high') {
            $stats['high_confidence']++;
        }
    }

    $stats['last_scan'] = $last_scan;

    return rest_ensure_response([
        'success' => true,
        'links' => $broken_links,
        'stats' => $stats,
        'message' => empty($broken_links)
            ? ($last_scan
                ? __('No broken links were stored in the most recent scan.', 'prorank-seo')
                : __('Run a broken links scan to populate this report.', 'prorank-seo'))
            : __('Broken links data is based on the most recent completed scan.', 'prorank-seo'),
    ]);
}

function refresh_broken_links_count_option(): void {
    global $wpdb;

    $table_name = $wpdb->prefix . 'prorank_broken_links';
    ensure_internal_linking_schema_ready();

    $count = (int) $wpdb->get_var(
        "SELECT COUNT(*) FROM {$table_name} WHERE status NOT IN ('ignored', 'fixed')" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    update_option('prorank_broken_links_count', $count);
}

function probe_broken_link_target_status(string $url): array {
    $request_args = [
        'timeout' => 5,
        'redirection' => 3,
        'user-agent' => 'Mozilla/5.0 (ProRank SEO Bot)',
        'sslverify' => false,
    ];

    $response = wp_remote_head($url, $request_args);
    $status_code = is_wp_error($response) ? 0 : (int) wp_remote_retrieve_response_code($response);

    if (is_wp_error($response) || $status_code === 405) {
        $response = wp_remote_get($url, $request_args);
        $status_code = is_wp_error($response) ? 0 : (int) wp_remote_retrieve_response_code($response);
    }

    if (is_wp_error($response)) {
        if (function_exists('curl_init')) {
            $curl = curl_init($url);
            if ($curl !== false) {
                curl_setopt_array($curl, [
                    CURLOPT_RETURNTRANSFER => true,
                    CURLOPT_FOLLOWLOCATION => true,
                    CURLOPT_MAXREDIRS => 3,
                    CURLOPT_CONNECTTIMEOUT => 5,
                    CURLOPT_TIMEOUT => 10,
                    CURLOPT_SSL_VERIFYPEER => false,
                    CURLOPT_SSL_VERIFYHOST => false,
                    CURLOPT_USERAGENT => 'Mozilla/5.0 (ProRank SEO Bot)',
                    CURLOPT_NOBODY => true,
                ]);

                curl_exec($curl);
                $curl_status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);

                if ($curl_status === 0) {
                    curl_setopt($curl, CURLOPT_NOBODY, false);
                    curl_exec($curl);
                    $curl_status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
                }

                $curl_error = curl_error($curl);
                curl_close($curl);

                if ($curl_status > 0) {
                    return [
                        'status_code' => $curl_status,
                        'error' => null,
                    ];
                }

                if ($curl_error !== '') {
                    return [
                        'status_code' => 0,
                        'error' => $curl_error,
                    ];
                }
            }
        }

        return [
            'status_code' => 0,
            'error' => $response->get_error_message(),
        ];
    }

    return [
        'status_code' => $status_code,
        'error' => null,
    ];
}

function inspect_broken_link_candidate(string $raw_url, string $site_host): ?array {
    $raw_url = trim(html_entity_decode($raw_url));
    $raw_url_lower = strtolower($raw_url);

    if ($raw_url === '' || $raw_url === '#' || preg_match('/^javascript:void\\(0\\);?$/i', $raw_url)) {
        return [
            'cache_key' => $raw_url === '' ? '#' : $raw_url,
            'url' => $raw_url === '' ? '#' : $raw_url,
            'is_broken' => true,
            'status_code' => 800,
            'confidence' => 'high',
        ];
    }

    if (strpos($raw_url, '\"') !== false || strpos($raw_url, '{{') !== false) {
        return [
            'cache_key' => $raw_url,
            'url' => $raw_url,
            'is_broken' => true,
            'status_code' => 925,
            'confidence' => 'high',
        ];
    }

    if (
        str_starts_with($raw_url_lower, 'mailto:') ||
        str_starts_with($raw_url_lower, 'tel:') ||
        str_starts_with($raw_url_lower, 'data:') ||
        str_starts_with($raw_url_lower, 'blob:') ||
        str_starts_with($raw_url_lower, 'about:') ||
        str_starts_with($raw_url_lower, 'chrome-extension:') ||
        str_starts_with($raw_url_lower, 'moz-extension:') ||
        str_starts_with($raw_url_lower, 'ms-browser-extension:')
    ) {
        return null;
    }

    $normalized_url = normalize_linking_url($raw_url);
    if ($normalized_url === '') {
        return null;
    }

    $check_url = $normalized_url;
    $fragment_position = strpos($normalized_url, '#');
    if ($fragment_position !== false) {
        $check_url = substr($normalized_url, 0, $fragment_position);
        if ($check_url === '') {
            $check_url = $normalized_url;
        }
    }

    $is_internal = is_internal_linking_url($check_url, $site_host);
    if ($is_internal) {
        $normalized_url = canonicalize_internal_linking_url($normalized_url);
        $check_url = canonicalize_internal_linking_url($check_url);
    }

    if ($is_internal) {
        $target_post_id = resolve_internal_post_id($check_url);
        if ($target_post_id > 0) {
            $target_post = get_post($target_post_id);
            if ($target_post instanceof \WP_Post && !in_array((string) $target_post->post_status, ['trash', 'auto-draft'], true)) {
                return [
                    'cache_key' => $check_url,
                    'url' => $normalized_url,
                    'is_broken' => false,
                    'status_code' => 200,
                    'confidence' => 'low',
                ];
            }
        }

        $path = normalize_internal_link_path((string) wp_parse_url($check_url, PHP_URL_PATH));
        if ($path !== '' && $path !== '/') {
            $home_path = function_exists('prorank_get_home_path')
                ? \prorank_get_home_path()
                : trailingslashit(wp_normalize_path(ABSPATH));
            $file_path = wp_normalize_path($home_path . ltrim($path, '/'));
            if (file_exists($file_path)) {
                return [
                    'cache_key' => $check_url,
                    'url' => $normalized_url,
                    'is_broken' => false,
                    'status_code' => 200,
                    'confidence' => 'low',
                ];
            }
        }
    }

    $probe = probe_broken_link_target_status($check_url);
    $status_code = (int) ($probe['status_code'] ?? 0);
    $is_broken = ($status_code === 0 || $status_code >= 400);
    $confidence = 'low';

    if ($is_broken) {
        if ($status_code === 403 || $status_code === 404 || ($status_code >= 500 && $status_code < 600)) {
            $confidence = 'high';
        } elseif ($status_code === 0) {
            $confidence = 'medium';
        } else {
            $confidence = 'medium';
        }
    }

    return [
        'cache_key' => $check_url,
        'url' => $normalized_url,
        'is_broken' => $is_broken,
        'status_code' => $status_code,
        'confidence' => $confidence,
    ];
}

/**
 * Scan for broken links - Enhanced version with batch processing and real progress
 */
function scan_broken_links(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $settings = get_internal_linking_runtime_settings();

    if (empty($settings['detect_broken_links'])) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Broken link detection is disabled in Internal Linking settings.', 'prorank-seo'),
        ]);
    }

    $post_types = get_internal_linking_scoped_post_types($settings);
    $post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);
    $status_placeholders = implode(',', array_fill(0, count($post_statuses), '%s'));
    $type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $scope_sql = "post_status IN ({$status_placeholders}) AND post_type IN ({$type_placeholders})";
    $scope_args = array_merge($post_statuses, $post_types);
    if ($max_age_days !== null) {
        $scope_sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $scope_args[] = $max_age_days;
    }
    
    // Update scan timestamp
    update_option('prorank_last_broken_scan', current_time('mysql'));
    
    // Generate scan ID
    $scan_id = uniqid('broken_scan_');
    $batch_size = $request->get_param('batch_size') ?: 5;
    
    // Get total posts count
    $total_posts = (int) $wpdb->get_var(
        $wpdb->prepare(
            "SELECT COUNT(*)
            FROM {$wpdb->posts}
            WHERE {$scope_sql}",
            ...$scope_args
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );
    
    // Initialize scan data
    $scan_data = [
        'scan_id' => $scan_id,
        'total' => (int) $total_posts,
        'current' => 0,
        'progress' => 0,
        'broken_count' => 0,
        'total_links' => 0,
        'batch_size' => $batch_size,
/* translators: %1$: value */
        'status' => sprintf(__('Initializing scan of %1$d posts...', 'prorank-seo'), $total_posts),
        'completed' => false,
        'checked_urls' => [],
    ];
    
    // Store initial scan data
    set_transient('prorank_scan_' . $scan_id, $scan_data, 3600);
    
    // Start processing immediately in background
    // For now, we'll process synchronously but update progress
    process_broken_links_scan($scan_id);
    
    return rest_ensure_response([
        'success' => true,
        'scan_id' => $scan_id,
        'message' => __('Broken links scan started', 'prorank-seo'),
        'total_posts' => $total_posts,
    ]);
}

/**
 * Process the broken links scan
 */
function process_broken_links_scan($scan_id) {
    global $wpdb;
    
    // Get scan data
    $scan_data = get_transient('prorank_scan_' . $scan_id);
    if (!$scan_data) {
        return;
    }
    
    $settings = get_internal_linking_runtime_settings();
    $post_types = get_internal_linking_scoped_post_types($settings);
    $post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);
    $status_placeholders = implode(',', array_fill(0, count($post_statuses), '%s'));
    $type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $scope_sql = "post_status IN ({$status_placeholders}) AND post_type IN ({$type_placeholders})";
    $scope_args = array_merge($post_statuses, $post_types);
    if ($max_age_days !== null) {
        $scope_sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $scope_args[] = $max_age_days;
    }

    // Get ALL scoped posts (we'll process them all but update progress as we go)
    $posts = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT ID, post_title, post_content, post_type
            FROM {$wpdb->posts}
            WHERE {$scope_sql}
            ORDER BY ID",
            ...$scope_args
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );
    
    $total_posts = count($posts);
    $current_post = 0;
    $total_links = 0;
    $broken_count = 0;
    $checked_urls = [];
    $site_host = (string) wp_parse_url(home_url(), PHP_URL_HOST);
    
    // Ensure canonical broken-links schema is available before writing scan results.
    $table_name = $wpdb->prefix . 'prorank_broken_links';
    ensure_internal_linking_schema_ready();
    
    // Clear old results
    $wpdb->query("DELETE FROM {$table_name} WHERE 1=1");
    
    foreach ($posts as $post) {
        $content = get_post_linking_content((int) $post->ID, (string) $post->post_content);
        $seen_post_urls = [];
        // Extract all links from post content
        preg_match_all('/<a[^>]+href=[\'"]([^\'"]+)[\'"][^>]*>([^<]*)<\/a>/i', $content, $matches);
        
        if (!empty($matches[1])) {
            foreach ($matches[1] as $index => $url) {
                $anchor_text = wp_strip_all_tags($matches[2][$index]);
                $inspection = inspect_broken_link_candidate((string) $url, $site_host);
                if ($inspection === null) {
                    continue;
                }

                $total_links++;
                $cache_key = (string) ($inspection['cache_key'] ?? $inspection['url']);
                $stored_url = (string) ($inspection['url'] ?? $url);
                
                // Skip if already checked
                if (isset($checked_urls[$cache_key])) {
                    if ($checked_urls[$cache_key]['is_broken']) {
                        $row_key = (int) $post->ID . '|' . $checked_urls[$cache_key]['url'];
                        if (isset($seen_post_urls[$row_key])) {
                            continue;
                        }

                        // Save broken link reference for this post
                        $wpdb->insert($table_name, [
                            'post_id' => $post->ID,
                            'url' => $checked_urls[$cache_key]['url'],
                            'anchor_text' => $anchor_text,
                            'context' => substr($content, max(0, strpos($content, (string) $url) - 100), 200),
                            'status_code' => $checked_urls[$cache_key]['code'],
                            'confidence' => $checked_urls[$cache_key]['confidence'],
                            'last_checked' => current_time('mysql'),
                        ]);
                        $seen_post_urls[$row_key] = true;
                        $broken_count++;
                    }
                    continue;
                }
                
                // Cache the result
                $checked_urls[$cache_key] = [
                    'url' => $stored_url,
                    'is_broken' => (bool) $inspection['is_broken'],
                    'code' => (int) $inspection['status_code'],
                    'confidence' => (string) $inspection['confidence'],
                ];
                
                // Save broken link to database
                if (!empty($inspection['is_broken'])) {
                    $row_key = (int) $post->ID . '|' . $stored_url;
                    if (isset($seen_post_urls[$row_key])) {
                        continue;
                    }

                    $wpdb->insert($table_name, [
                        'post_id' => $post->ID,
                        'url' => $stored_url,
                        'anchor_text' => $anchor_text,
                        'context' => substr($content, max(0, strpos($content, (string) $url) - 100), 200),
                        'status_code' => (int) $inspection['status_code'],
                        'confidence' => (string) $inspection['confidence'],
                        'last_checked' => current_time('mysql'),
                    ]);
                    $seen_post_urls[$row_key] = true;
                    $broken_count++;
                }
            }
        }
    }
    
    // Store scan results
    set_transient('prorank_scan_' . $scan_id, [
        'progress' => 100,
/* translators: %1$: value, %2$: value */
        'status' => sprintf(__('Scan complete. Found %1$d broken links out of %2$d total links.', 'prorank-seo'), $broken_count, $total_links),
        'completed' => true,
        'total_links' => $total_links,
        'broken_count' => $broken_count,
    ], 3600);

    refresh_broken_links_count_option();
    
    return rest_ensure_response([
        'success' => true,
        'scan_id' => $scan_id,
        'message' => __('Broken links scan completed', 'prorank-seo'),
        'stats' => [
            'total_links' => $total_links,
            'broken_count' => $broken_count,
        ]
    ]);
}

/**
 * Ignore a broken link
 */
function ignore_broken_link(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $link_id = (int) $request->get_param('id');

    if ($link_id <= 0) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Invalid link ID', 'prorank-seo'),
        ]);
    }

    $table_name = $wpdb->prefix . 'prorank_broken_links';
    ensure_internal_linking_schema_ready();
    $updated = $wpdb->update(
        $table_name,
        [
            'status' => 'ignored',
            'last_checked' => current_time('mysql'),
        ],
        ['id' => $link_id],
        ['%s', '%s'],
        ['%d']
    );

    if ($updated === false) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Failed to ignore link', 'prorank-seo'),
        ]);
    }

    refresh_broken_links_count_option();

    return rest_ensure_response([
        'success' => true,
        'message' => __('Link ignored', 'prorank-seo'),
    ]);
}

/**
 * Delete broken links
 */
function delete_broken_links(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $link_ids = array_values(array_filter(array_map('intval', (array) $request->get_param('link_ids'))));
    
    if (empty($link_ids)) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('No links specified', 'prorank-seo'),
        ]);
    }
    $table_name = $wpdb->prefix . 'prorank_broken_links';
    ensure_internal_linking_schema_ready();
    $placeholders = implode(',', array_fill(0, count($link_ids), '%d'));
    $deleted = $wpdb->query(
        $wpdb->prepare(
            "DELETE FROM {$table_name} WHERE id IN ({$placeholders})",
            ...$link_ids
        ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    );

    if ($deleted === false) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Failed to delete broken links', 'prorank-seo'),
        ]);
    }

    refresh_broken_links_count_option();
    
    return rest_ensure_response([
        'success' => true,
/* translators: %1$: value */
        'message' => sprintf(__('%1$d links deleted', 'prorank-seo'), (int) $deleted),
    ]);
}

/**
 * Resolve primary category data for link-map silo coloring.
 *
 * @param int $post_id Post ID.
 * @return array{id:int,name:string}
 */
function get_linkmap_primary_category_data(int $post_id): array {
    $primary_term = get_post_meta($post_id, '_prorank_seo_primary_category', true);
    if (!$primary_term) {
        $primary_term = get_post_meta($post_id, '_yoast_wpseo_primary_category', true);
    }
    if (!$primary_term) {
        $primary_term = get_post_meta($post_id, 'rank_math_primary_category', true);
    }

    if ($primary_term) {
        $term = get_term((int) $primary_term, 'category');
        if ($term instanceof \WP_Term && !is_wp_error($term)) {
            return [
                'id' => (int) $term->term_id,
                'name' => (string) $term->name,
            ];
        }
    }

    $categories = wp_get_post_categories($post_id, ['fields' => 'all']);
    if (!empty($categories) && !is_wp_error($categories) && $categories[0] instanceof \WP_Term) {
        return [
            'id' => (int) $categories[0]->term_id,
            'name' => (string) $categories[0]->name,
        ];
    }

    return [
        'id' => 0,
        'name' => __('Uncategorized', 'prorank-seo'),
    ];
}

/**
 * Build a link-map node from a WordPress post.
 *
 * @param \WP_Post $post Post object.
 * @return array<string,mixed>
 */
function build_linkmap_post_node_data(\WP_Post $post): array {
    $primary_category = get_linkmap_primary_category_data((int) $post->ID);

    return [
        'id' => (string) $post->ID,
        'wp_id' => (int) $post->ID,
        'title' => (string) $post->post_title,
        'name' => (string) $post->post_title,
        'label' => (string) $post->post_title,
        'type' => (string) $post->post_type,
        'post_type' => (string) $post->post_type,
        'url' => (string) get_permalink($post->ID),
        'date' => (string) $post->post_date,
        'inbound_links' => 0,
        'outbound_links' => 0,
        'outbound_internal' => 0,
        'outbound_external' => 0,
        'is_orphan' => false,
        'orphan' => false,
        'is_missing' => false,
        'is_pillar' => (bool) get_post_meta((int) $post->ID, '_prorank_is_pillar_content', true),
        'primary_category' => (int) $primary_category['id'],
        'category_name' => (string) $primary_category['name'],
        'size' => 5,
        'color' => $post->post_type === 'post' ? '#3b82f6' : '#10b981',
    ];
}

/**
 * Build a synthetic link-map node for an unresolved internal target.
 *
 * @param string $normalized_target_path Normalized path.
 * @param string $url                    Raw URL.
 * @return array<string,mixed>
 */
function build_linkmap_missing_node_data(string $normalized_target_path, string $url): array {
    return [
        'id' => 'missing:' . md5($normalized_target_path),
        'wp_id' => 0,
        'title' => sprintf(__('Missing: %1$s', 'prorank-seo'), $normalized_target_path),
        'name' => $normalized_target_path,
        'label' => sprintf(__('Missing: %1$s', 'prorank-seo'), $normalized_target_path),
        'type' => 'missing',
        'post_type' => 'missing',
        'url' => $url,
        'date' => null,
        'inbound_links' => 0,
        'outbound_links' => 0,
        'outbound_internal' => 0,
        'outbound_external' => 0,
        'is_orphan' => false,
        'orphan' => false,
        'is_missing' => true,
        'is_pillar' => false,
        'primary_category' => 0,
        'category_name' => '',
        'size' => 5,
        'color' => '#f59e0b',
    ];
}

/**
 * Get link map visualization data
 */
function get_linkmap_visualization_data(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;
    $settings = get_internal_linking_runtime_settings();
    $is_linking_enabled = !empty($settings['enabled']);
    $is_map_enabled = !empty($settings['enable_link_map']);
    if (!$is_linking_enabled || !$is_map_enabled) {
        return rest_ensure_response([
            'success' => true,
            'data' => [
                'nodes' => [],
                'links' => [],
                'metrics' => [
                    'total_nodes' => 0,
                    'total_links' => 0,
                    'resolved_links' => 0,
                    'unresolved_links' => 0,
                    'orphan_count' => 0,
                    'link_density' => 0,
                ],
                'stats' => [
                    'total_nodes' => 0,
                    'total_links' => 0,
                    'resolved_links' => 0,
                    'unresolved_links' => 0,
                    'orphan_count' => 0,
                ],
            ],
            'message' => __('Link Map is disabled in Internal Linking settings.', 'prorank-seo'),
        ]);
    }

    $allowed_post_types = get_internal_linking_scoped_post_types($settings);
    $post_types = explode(',', (string) ($request->get_param('post_types') ?: implode(',', $allowed_post_types)));
    $post_types = array_values(array_filter(array_map('sanitize_key', $post_types)));
    $post_types = array_values(array_intersect($allowed_post_types, $post_types));
    if (empty($post_types)) {
        $post_types = $allowed_post_types;
    }
    $post_statuses = get_internal_linking_scoped_post_statuses($settings);
    $max_age_days = get_internal_linking_max_age_days($settings);
    $max_nodes = max(10, min(500, (int) ($request->get_param('max_nodes') ?: 100)));
    $depth = max(1, min(3, (int) ($request->get_param('depth') ?: 2)));

    // Build a recent seed set, then traverse internal links from those seeds.
    $seed_limit = min($max_nodes, max(10, (int) ceil($max_nodes / ($depth + 1))));
    $post_type_placeholders = implode(',', array_fill(0, count($post_types), '%s'));
    $status_placeholders = implode(',', array_fill(0, count($post_statuses), '%s'));
    $query_args = array_merge($post_statuses, $post_types);
    $where_sql = "post_status IN ({$status_placeholders}) AND post_type IN ({$post_type_placeholders})";
    if ($max_age_days !== null) {
        $where_sql .= ' AND post_date >= DATE_SUB(NOW(), INTERVAL %d DAY)';
        $query_args[] = $max_age_days;
    }
    $query_args[] = $seed_limit;
    // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
    $seed_posts = $wpdb->get_results($wpdb->prepare(
        "SELECT ID, post_title, post_type, post_status, post_date
         FROM {$wpdb->posts}
         WHERE {$where_sql}
         ORDER BY post_date DESC
         LIMIT %d",
        ...$query_args
    ));

    if (empty($seed_posts)) {
        return rest_ensure_response([
            'success' => true,
            'data' => [
                'nodes' => [],
                'links' => [],
            ]
        ]);
    }
    
    $nodes_by_id = [];
    $links_by_key = [];
    $permalink_path_map = [];
    $processed_source_ids = [];
    $queued_depths = [];
    $post_cache = [];
    $site_host = (string) wp_parse_url(home_url(), PHP_URL_HOST);
    $queue = new \SplQueue();

    $get_cached_post = static function(int $post_id) use (&$post_cache): ?\WP_Post {
        if (!array_key_exists($post_id, $post_cache)) {
            $cached = get_post($post_id);
            $post_cache[$post_id] = $cached instanceof \WP_Post ? $cached : null;
        }

        return $post_cache[$post_id];
    };

    $register_post_node = static function(\WP_Post $post) use (&$nodes_by_id, &$permalink_path_map): void {
        $node_key = (string) $post->ID;
        if (!isset($nodes_by_id[$node_key])) {
            $nodes_by_id[$node_key] = build_linkmap_post_node_data($post);
        }

        $normalized_path = normalize_internal_link_path((string) wp_parse_url((string) $nodes_by_id[$node_key]['url'], PHP_URL_PATH));
        $permalink_path_map[$normalized_path] = (int) $post->ID;
    };

    $queue_node_for_depth = static function(int $post_id, int $current_depth) use (&$queue, &$queued_depths, $depth): void {
        if ($current_depth >= $depth) {
            return;
        }

        if (!isset($queued_depths[$post_id]) || $current_depth < $queued_depths[$post_id]) {
            $queued_depths[$post_id] = $current_depth;
            $queue->enqueue([
                'post_id' => $post_id,
                'depth' => $current_depth,
            ]);
        }
    };

    foreach ($seed_posts as $seed_post) {
        $seed_id = (int) $seed_post->ID;
        $seed_wp_post = $get_cached_post($seed_id);
        if (!$seed_wp_post instanceof \WP_Post) {
            continue;
        }

        $register_post_node($seed_wp_post);
        $queue_node_for_depth($seed_id, 0);
    }

    while (!$queue->isEmpty()) {
        $queue_item = $queue->dequeue();
        $source_id = (int) ($queue_item['post_id'] ?? 0);
        $current_depth = (int) ($queue_item['depth'] ?? 0);

        if ($source_id <= 0 || isset($processed_source_ids[$source_id])) {
            continue;
        }
        $processed_source_ids[$source_id] = true;

        $source_post = $get_cached_post($source_id);
        if (!$source_post instanceof \WP_Post) {
            continue;
        }

        $source_key = (string) $source_id;
        if (!isset($nodes_by_id[$source_key])) {
            $register_post_node($source_post);
        }

        $content = get_post_linking_content($source_id, (string) get_post_field('post_content', $source_id));
        $hrefs = get_post_linking_urls($source_id, $content);
        if (empty($hrefs) || !isset($nodes_by_id[$source_key])) {
            continue;
        }

        foreach ($hrefs as $raw_url) {
            $url = normalize_linking_url((string) $raw_url);
            if ($url === '') {
                continue;
            }

            if (!is_internal_linking_url($url, $site_host)) {
                $nodes_by_id[$source_key]['outbound_external']++;
                continue;
            }

            $nodes_by_id[$source_key]['outbound_internal']++;
            $target_id = resolve_internal_post_id($url, $permalink_path_map);
            $target_key = '';
            $link_type = 'internal';

            if ($target_id > 0) {
                $target_key = (string) $target_id;

                if (!isset($nodes_by_id[$target_key])) {
                    $target_post = $get_cached_post($target_id);
                    if (
                        $target_post instanceof \WP_Post
                        && in_array((string) $target_post->post_status, $post_statuses, true)
                        && in_array((string) $target_post->post_type, $post_types, true)
                    ) {
                        if (count($nodes_by_id) >= $max_nodes) {
                            continue;
                        }
                        $register_post_node($target_post);
                    }
                }
            } else {
                $normalized_target_path = normalize_internal_link_path((string) wp_parse_url($url, PHP_URL_PATH));
                if ($normalized_target_path === '/') {
                    continue;
                }
                $target_key = 'missing:' . md5($normalized_target_path);
                $link_type = 'internal_missing';

                if (!isset($nodes_by_id[$target_key]) && count($nodes_by_id) < $max_nodes) {
                    $nodes_by_id[$target_key] = build_linkmap_missing_node_data($normalized_target_path, $url);
                }
            }

            if ($target_key === '' || !isset($nodes_by_id[$target_key]) || $target_key === $source_key) {
                continue;
            }

            $link_key = $source_key . '-' . $target_key;
            if (!isset($links_by_key[$link_key])) {
                $links_by_key[$link_key] = [
                    'source' => $source_key,
                    'target' => $target_key,
                    'type' => $link_type,
                    'value' => 1,
                ];
            } else {
                $links_by_key[$link_key]['value'] = (int) $links_by_key[$link_key]['value'] + 1;
            }

            if ($link_type === 'internal' && $current_depth + 1 < $depth && is_numeric($target_key)) {
                $queue_node_for_depth((int) $target_key, $current_depth + 1);
            }
        }
    }

    foreach ($links_by_key as $link) {
        $source_key = (string) $link['source'];
        $target_key = (string) $link['target'];
        if (isset($nodes_by_id[$source_key])) {
            $nodes_by_id[$source_key]['outbound_links']++;
        }
        if (isset($nodes_by_id[$target_key])) {
            $nodes_by_id[$target_key]['inbound_links']++;
        }
    }

    foreach ($nodes_by_id as &$node) {
        $node['is_orphan'] = empty($node['is_missing']) && ($node['inbound_links'] === 0);
        $node['orphan'] = $node['is_orphan'];
        $node['size'] = 5 + min(20, (int) $node['inbound_links'] * 2);
    }
    unset($node);

    $nodes = array_values($nodes_by_id);
    $links = array_values($links_by_key);
    if (empty($settings['show_orphan_pages'])) {
        $nodes = array_values(array_filter($nodes, static function(array $node): bool {
            return !empty($node['is_missing']) || empty($node['is_orphan']);
        }));
        $visible_node_ids = array_fill_keys(array_map(static function(array $node): string {
            return (string) ($node['id'] ?? '');
        }, $nodes), true);
        $links = array_values(array_filter($links, static function(array $link) use ($visible_node_ids): bool {
            $source = (string) ($link['source'] ?? '');
            $target = (string) ($link['target'] ?? '');
            return isset($visible_node_ids[$source], $visible_node_ids[$target]);
        }));
    }

    if (empty($settings['show_link_strength'])) {
        foreach ($links as &$link) {
            $link['value'] = 1;
        }
        unset($link);
    }

    $resolved_link_count = count(array_filter($links, static function(array $link): bool {
        return ($link['type'] ?? '') === 'internal';
    }));
    $unresolved_link_count = count(array_filter($links, static function(array $link): bool {
        return ($link['type'] ?? '') === 'internal_missing';
    }));
    
    // Calculate link density (total links / total possible links)
    $node_count = count($nodes);
    $link_count = count($links);
    $possible_links = $node_count * ($node_count - 1); // Directed graph
    $link_density = $possible_links > 0 ? $link_count / $possible_links : 0;

    return rest_ensure_response([
        'success' => true,
        'data' => [
            'nodes' => $nodes,
            'links' => $links,
            // Use 'metrics' to match frontend expectations
            'metrics' => [
                'total_nodes' => $node_count,
                'total_links' => $link_count,
                'resolved_links' => $resolved_link_count,
                'unresolved_links' => $unresolved_link_count,
                'orphan_count' => count(array_filter($nodes, function($n) { return $n['orphan']; })),
                'link_density' => $link_density,
            ],
            // Keep 'stats' for backwards compatibility
            'stats' => [
                'total_nodes' => $node_count,
                'total_links' => $link_count,
                'resolved_links' => $resolved_link_count,
                'unresolved_links' => $unresolved_link_count,
                'orphan_count' => count(array_filter($nodes, function($n) { return $n['orphan']; })),
            ]
        ]
    ]);
}

/**
 * Get detailed information about a specific node
 */
function get_linkmap_node_details(WP_REST_Request $request): WP_REST_Response {
    $post_id = (int) $request->get_param('id');
    $post = get_post($post_id);
    
    if (!$post) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Post not found', 'prorank-seo'),
        ]);
    }
    
    global $wpdb;
    
    // Build inbound links using builder-aware extraction.
    $inbound_links = [];
    $source_ids = get_posts([
        'post_type' => ['post', 'page'],
        'post_status' => 'publish',
        'numberposts' => -1,
        'fields' => 'ids',
        'exclude' => [$post_id],
    ]);
    
    // Build a path map for common internal post types to improve permalink resolution.
    $detail_path_map = [];
    $detail_ids = get_posts([
        'post_type' => ['post', 'page'],
        'post_status' => 'publish',
        'numberposts' => -1,
        'fields' => 'ids',
    ]);
    foreach ($detail_ids as $detail_id) {
        $path = normalize_internal_link_path((string) wp_parse_url((string) get_permalink((int) $detail_id), PHP_URL_PATH));
        $detail_path_map[$path] = (int) $detail_id;
    }

    // Get all outbound links
    $content = get_post_linking_content((int) $post->ID, (string) $post->post_content);
    $site_host = (string) wp_parse_url(home_url(), PHP_URL_HOST);
    $content_urls = get_post_linking_urls((int) $post->ID, $content);
    
    $outbound_links = [];
    foreach ($content_urls as $raw_url) {
        $url = normalize_linking_url((string) $raw_url);
        if ($url === '') {
            continue;
        }

        if (is_internal_linking_url($url, $site_host)) {
            $post_id_from_url = resolve_internal_post_id((string) $url, $detail_path_map);
            if ($post_id_from_url) {
                $linked_post = get_post($post_id_from_url);
                if ($linked_post) {
                    $outbound_links[] = [
                        'id' => $linked_post->ID,
                        'title' => $linked_post->post_title,
                        'type' => 'internal',
                        'anchor' => '',
                        'url' => $url,
                    ];
                }
            } else {
                $outbound_links[] = [
                    'id' => 0,
                    /* translators: %1$s: Missing internal target path. */
                    'title' => sprintf(__('Missing target: %1$s', 'prorank-seo'), normalize_internal_link_path((string) wp_parse_url($url, PHP_URL_PATH))),
                    'type' => 'internal_missing',
                    'anchor' => '',
                    'url' => $url,
                ];
            }
        } else if (str_starts_with($url, 'http')) {
            $outbound_links[] = [
                'id' => 0,
                'title' => $url,
                'type' => 'external',
                'anchor' => '',
                'url' => $url,
            ];
        }
    }

    foreach ($source_ids as $source_id) {
        $source_urls = get_post_linking_urls((int) $source_id, (string) get_post_field('post_content', (int) $source_id));
        $has_match = false;

        foreach ($source_urls as $raw_source_url) {
            $source_url = normalize_linking_url((string) $raw_source_url);
            if ($source_url === '' || !is_internal_linking_url($source_url, $site_host)) {
                continue;
            }

            $resolved_target_id = resolve_internal_post_id($source_url, $detail_path_map);
            if ($resolved_target_id === $post_id) {
                $has_match = true;
                break;
            }
        }

        if (!$has_match) {
            continue;
        }

        $source_post = get_post((int) $source_id);
        if (!$source_post instanceof \WP_Post) {
            continue;
        }

        $inbound_links[] = (object) [
            'ID' => (int) $source_post->ID,
            'post_title' => (string) $source_post->post_title,
            'post_type' => (string) $source_post->post_type,
        ];

        if (count($inbound_links) >= 20) {
            break;
        }
    }
    
    return rest_ensure_response([
        'success' => true,
        'data' => [
            'id' => $post->ID,
            'title' => $post->post_title,
            'type' => $post->post_type,
            'status' => $post->post_status,
            'date' => $post->post_date,
            'url' => get_permalink($post->ID),
            'edit_url' => get_edit_post_link($post->ID, 'raw'),
            'word_count' => str_word_count(wp_strip_all_tags($content)),
            'inbound_links' => array_map(function($link) {
                return [
                    'id' => $link->ID,
                    'title' => $link->post_title,
                    'type' => $link->post_type,
                    'url' => get_permalink($link->ID),
                ];
            }, $inbound_links),
            'outbound_links' => $outbound_links,
            'stats' => [
                'inbound_count' => count($inbound_links),
                'outbound_internal_count' => count(array_filter($outbound_links, function($l) { 
                    return $l['type'] === 'internal'; 
                })),
                'outbound_unresolved_count' => count(array_filter($outbound_links, function($l) { 
                    return $l['type'] === 'internal_missing'; 
                })),
                'outbound_external_count' => count(array_filter($outbound_links, function($l) { 
                    return $l['type'] === 'external'; 
                })),
            ]
        ]
    ]);
}

/**
 * Export link map data
 */
function export_linkmap_data(WP_REST_Request $request): WP_REST_Response {
    $format = $request->get_param('format') ?: 'json';
    $requested_max_nodes = (int) ($request->get_param('maxNodes') ?: $request->get_param('max_nodes') ?: 250);
    $requested_depth = (int) ($request->get_param('depth') ?: 2);
    
    // Get the link map data
    $data_request = new WP_REST_Request('GET');
    $data_request->set_param('post_types', $request->get_param('post_types'));
    $data_request->set_param('max_nodes', max(10, min(1000, $requested_max_nodes)));
    $data_request->set_param('depth', max(1, min(3, $requested_depth)));
    $response = get_linkmap_visualization_data($data_request);
    $data = $response->get_data();
    
    if (!$data['success']) {
        return $response;
    }
    
    $export_data = $data['data'];
    
    // Generate filename
    $filename = 'link-map-' . wp_date('Y-m-d-His');
    
    if ($format === 'csv') {
        // Convert to CSV format
        $csv_data = "Source,Target,Type\n";
        foreach ($export_data['links'] as $link) {
            $source_node = array_filter($export_data['nodes'], function($n) use ($link) {
                return $n['id'] === $link['source'];
            });
            $target_node = array_filter($export_data['nodes'], function($n) use ($link) {
                return $n['id'] === $link['target'];
            });
            
            $source_title = $source_node ? reset($source_node)['label'] : 'Unknown';
            $target_title = $target_node ? reset($target_node)['label'] : 'Unknown';
            
            $csv_data .= '"' . str_replace('"', '""', $source_title) . '",';
            $csv_data .= '"' . str_replace('"', '""', $target_title) . '",';
            $csv_data .= $link['type'] . "\n";
        }
        
        return rest_ensure_response([
            'success' => true,
            'filename' => $filename . '.csv',
            'data' => base64_encode($csv_data),
            'mime_type' => 'text/csv',
        ]);
    }
    
    // Default to JSON
    return rest_ensure_response([
        'success' => true,
        'filename' => $filename . '.json',
        'data' => base64_encode(json_encode($export_data, JSON_PRETTY_PRINT)),
        'mime_type' => 'application/json',
    ]);
}

/**
 * Validate a URL returns a valid HTTP response (2xx/3xx)
 *
 * @param string $url URL to validate
 * @return array ['valid' => bool, 'status_code' => int, 'error' => string|null]
 */
function validate_target_url(string $url): array {
    // Check URL format
    if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
        return [
            'valid' => false,
            'status_code' => 0,
            'error' => __('Invalid URL format', 'prorank-seo'),
        ];
    }

    // Block dangerous protocols
    $parsed = wp_parse_url($url);
    if (!in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) {
        return [
            'valid' => false,
            'status_code' => 0,
            'error' => __('Only HTTP/HTTPS URLs are allowed', 'prorank-seo'),
        ];
    }

    // Check if it's an internal URL (use url_to_postid)
    $site_url = get_site_url();
    if (strpos($url, $site_url) === 0) {
        $post_id = url_to_postid($url);
        if ($post_id > 0) {
            $post = get_post($post_id);
            if ($post && $post->post_status === 'publish') {
                return [
                    'valid' => true,
                    'status_code' => 200,
                    'error' => null,
                ];
            }
        }
        return [
            'valid' => false,
            'status_code' => 404,
            'error' => __('Internal page not found or not published', 'prorank-seo'),
        ];
    }

    // For external URLs, perform HTTP HEAD request
    $response = wp_remote_head($url, [
        'timeout' => 10,
        'redirection' => 5,
        'user-agent' => 'Mozilla/5.0 (compatible; ProRankSEO/1.0; +https://prorank.io)',
        'sslverify' => true,
    ]);

    if (is_wp_error($response)) {
        // Fallback to GET if HEAD fails
        $response = wp_remote_get($url, [
            'timeout' => 10,
            'redirection' => 5,
            'user-agent' => 'Mozilla/5.0 (compatible; ProRankSEO/1.0; +https://prorank.io)',
            'sslverify' => true,
        ]);

        if (is_wp_error($response)) {
            return [
                'valid' => false,
                'status_code' => 0,
                'error' => $response->get_error_message(),
            ];
        }
    }

    $status_code = wp_remote_retrieve_response_code($response);

    // Accept 2xx and 3xx status codes
    if ($status_code >= 200 && $status_code < 400) {
        return [
            'valid' => true,
            'status_code' => $status_code,
            'error' => null,
        ];
    }

    return [
        'valid' => false,
        'status_code' => $status_code,
/* translators: %1$: value */
        'error' => sprintf(__('URL returned HTTP %1$d', 'prorank-seo'), $status_code),
    ];
}

/**
 * Fix a single broken link by replacing with new URL
 */
function fix_broken_link(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;

    // Verify nonce
    $nonce = $request->get_header('X-WP-Nonce');
    if (!wp_verify_nonce($nonce, 'wp_rest')) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Security check failed', 'prorank-seo'),
        ]);
    }

    // Verify capability
    if (!current_user_can('edit_posts')) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Permission denied', 'prorank-seo'),
        ]);
    }

    $link_id = (int) $request->get_param('id');
    $new_url = $request->get_param('new_url');
    $action = $request->get_param('action'); // 'replace', 'remove', 'unlink'

    if (!$link_id) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Invalid link ID', 'prorank-seo'),
        ]);
    }

    // Get broken link from database
    $table_name = $wpdb->prefix . 'prorank_broken_links';
    $broken_link = $wpdb->get_row($wpdb->prepare(
        "SELECT * FROM {$table_name} WHERE id = %d",
        $link_id
    ));

    if (!$broken_link) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Broken link not found', 'prorank-seo'),
        ]);
    }

    // If replacing with new URL, validate it first
    if ($action === 'replace' && !empty($new_url)) {
        $validation = validate_target_url($new_url);
        if (!$validation['valid']) {
            return rest_ensure_response([
                'success' => false,
                'message' => sprintf(
/* translators: %1$: value */
                    __('New URL validation failed: %1$s', 'prorank-seo'),
                    $validation['error']
                ),
                'status_code' => $validation['status_code'],
            ]);
        }
    }

    // Get the post content
    $post = get_post($broken_link->post_id);
    if (!$post) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Post not found', 'prorank-seo'),
        ]);
    }

    // Check user can edit this post
    if (!current_user_can('edit_post', $post->ID)) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('You do not have permission to edit this post', 'prorank-seo'),
        ]);
    }

    $content = $post->post_content;
    $old_url = $broken_link->url;

    switch ($action) {
        case 'replace':
            // Replace old URL with new URL
            $content = str_replace(
                ['href="' . $old_url . '"', "href='" . $old_url . "'"],
                ['href="' . esc_url($new_url) . '"', "href='" . esc_url($new_url) . "'"],
                $content
            );
            break;

        case 'remove':
            // Remove the entire anchor tag but keep the anchor text
            $pattern = '/<a\s+[^>]*href=["\']' . preg_quote($old_url, '/') . '["\'][^>]*>(.*?)<\/a>/is';
            $content = preg_replace($pattern, '$1', $content);
            break;

        case 'unlink':
        default:
            // Same as remove - convert link to plain text
            $pattern = '/<a\s+[^>]*href=["\']' . preg_quote($old_url, '/') . '["\'][^>]*>(.*?)<\/a>/is';
            $content = preg_replace($pattern, '$1', $content);
            break;
    }

    // Update the post
    $result = wp_update_post([
        'ID' => $post->ID,
        'post_content' => $content,
    ], true);

    if (is_wp_error($result)) {
        return rest_ensure_response([
            'success' => false,
            'message' => $result->get_error_message(),
        ]);
    }

    // Mark the broken link as fixed in database
    $wpdb->update(
        $table_name,
        [
            'status' => 'fixed',
            'last_checked' => current_time('mysql'),
        ],
        ['id' => $link_id],
        ['%s', '%s'],
        ['%d']
    );

    return rest_ensure_response([
        'success' => true,
/* translators: %1$: value */
        'message' => sprintf(__('Link fixed successfully in "%1$s"', 'prorank-seo'), $post->post_title),
        'post_id' => $post->ID,
        'action' => $action,
    ]);
}

/**
 * Fix multiple broken links in batch
 */
function fix_broken_links_batch(WP_REST_Request $request): WP_REST_Response {
    global $wpdb;

    // Verify nonce
    $nonce = $request->get_header('X-WP-Nonce');
    if (!wp_verify_nonce($nonce, 'wp_rest')) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Security check failed', 'prorank-seo'),
        ]);
    }

    // Verify capability
    if (!current_user_can('edit_posts')) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('Permission denied', 'prorank-seo'),
        ]);
    }

    $link_ids = $request->get_param('link_ids');
    $action = $request->get_param('action') ?: 'remove'; // 'remove', 'ignore'
    $new_urls = $request->get_param('new_urls') ?: []; // Map of link_id => new_url for replacements

    if (empty($link_ids) || !is_array($link_ids)) {
        return rest_ensure_response([
            'success' => false,
            'message' => __('No links specified', 'prorank-seo'),
        ]);
    }

    // Limit batch size to prevent timeout
    $link_ids = array_slice($link_ids, 0, 50);

    $results = [
        'fixed' => 0,
        'failed' => 0,
        'ignored' => 0,
        'errors' => [],
    ];

    $table_name = $wpdb->prefix . 'prorank_broken_links';

    foreach ($link_ids as $link_id) {
        $link_id = (int) $link_id;

        // Get broken link from database
        $broken_link = $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM {$table_name} WHERE id = %d AND status = 'active'",
            $link_id
        ));

        if (!$broken_link) {
            $results['failed']++;
/* translators: %1$: value */
            $results['errors'][] = sprintf(__('Link #%1$d not found', 'prorank-seo'), $link_id);
            continue;
        }

        if ($action === 'ignore') {
            // Just mark as ignored
            $wpdb->update(
                $table_name,
                ['status' => 'ignored', 'last_checked' => current_time('mysql')],
                ['id' => $link_id],
                ['%s', '%s'],
                ['%d']
            );
            $results['ignored']++;
            continue;
        }

        // If we have a replacement URL, validate it
        if (isset($new_urls[$link_id]) && !empty($new_urls[$link_id])) {
            $validation = validate_target_url($new_urls[$link_id]);
            if (!$validation['valid']) {
                $results['failed']++;
                $results['errors'][] = sprintf(
/* translators: %1$: value, %2$: value */
                    __('Link #%1$d: New URL validation failed - %2$s', 'prorank-seo'),
                    $link_id,
                    $validation['error']
                );
                continue;
            }
        }

        // Get the post
        $post = get_post($broken_link->post_id);
        if (!$post || !current_user_can('edit_post', $post->ID)) {
            $results['failed']++;
/* translators: %1$: value */
            $results['errors'][] = sprintf(__('Link #%1$d: Cannot edit post', 'prorank-seo'), $link_id);
            continue;
        }

        $content = $post->post_content;
        $old_url = $broken_link->url;

        // Determine action: replace if we have new URL, otherwise remove
        if (isset($new_urls[$link_id]) && !empty($new_urls[$link_id])) {
            $new_url = esc_url($new_urls[$link_id]);
            $content = str_replace(
                ['href="' . $old_url . '"', "href='" . $old_url . "'"],
                ['href="' . $new_url . '"', "href='" . $new_url . "'"],
                $content
            );
        } else {
            // Remove the link but keep anchor text
            $pattern = '/<a\s+[^>]*href=["\']' . preg_quote($old_url, '/') . '["\'][^>]*>(.*?)<\/a>/is';
            $content = preg_replace($pattern, '$1', $content);
        }

        // Update the post
        $result = wp_update_post([
            'ID' => $post->ID,
            'post_content' => $content,
        ], true);

        if (is_wp_error($result)) {
            $results['failed']++;
            $results['errors'][] = sprintf(
/* translators: %1$: value, %2$: value */
                __('Link #%1$d: %2$s', 'prorank-seo'),
                $link_id,
                $result->get_error_message()
            );
            continue;
        }

        // Mark as fixed
        $wpdb->update(
            $table_name,
            ['status' => 'fixed', 'last_checked' => current_time('mysql')],
            ['id' => $link_id],
            ['%s', '%s'],
            ['%d']
        );

        $results['fixed']++;
    }

    return rest_ensure_response([
        'success' => true,
        'message' => sprintf(
/* translators: %1$: value, %2$: value, %3$: value, %4$: value */
            __('Processed %1$d links: %2$d fixed, %3$d failed, %4$d ignored', 'prorank-seo'),
            count($link_ids),
            $results['fixed'],
            $results['failed'],
            $results['ignored']
        ),
        'results' => $results,
    ]);
}
