<?php
/**
 * Tam Rank™ Internal Link Graph — orphan page detection (F14)
 *
 * Builds a site-wide inbound-internal-link map over the audit post types and
 * flags orphan pages (0 inbound internal links) and weak pages (< WEAK_THRESHOLD).
 * FREE tier: detection only. The AI fix (link suggestions) is the PRO
 * NIELS-01 feature and lives outside this class.
 *
 * Scope: links are read from post_content (Gutenberg/classic store plain
 * <a href> markup there) plus the items of every registered nav menu — a page
 * a menu points at is reachable and linked, so calling it an orphan was a
 * false positive. Links that only exist in page-builder meta, in a theme
 * template, in a widget or in a block theme's navigation block are still NOT
 * seen — a documented limitation, surfaced in the UI copy rather than hidden.
 *
 * @package TamRank
 * @since 1.3.7
 */

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

class TamRank_Link_Graph {

    /**
     * Option holding the latest scan snapshot (autoload=no; ~100KB on large sites).
     */
    const OPTION_KEY = 'tam_rank_link_graph';

    /**
     * Post IDs the user chose to keep out of the orphan list (autoload=no).
     *
     * Deliberately NOT tam_rank_audit_excluded_posts: excluding a page from the
     * whole audit is far too blunt for "I know /privacy/ has no contextual
     * links" — that page still deserves its meta and content score.
     */
    const IGNORED_OPTION_KEY = 'tam_rank_link_graph_ignored';

    /**
     * Recurring cron hook (weekly). Cleared on deactivation + uninstall.
     */
    const CRON_HOOK = 'tamrank_link_graph_scan';

    /**
     * Transient lock against overlapping runs (cron + "Scan now" racing).
     */
    const LOCK_TRANSIENT = 'tam_rank_link_graph_lock';

    /**
     * Pages with fewer inbound links than this are "weak"; 0 inbound = orphan.
     */
    const WEAK_THRESHOLD = 2;

    /**
     * Posts fetched per query while enumerating (memory bound, not a result cap).
     */
    const BATCH_SIZE = 200;

    /**
     * Hard cap on scanned posts. When hit, the snapshot is marked truncated=true
     * and the UI says so — never silent truncation.
     */
    const MAX_POSTS = 5000;

    /**
     * Column key for the posts-list link counter.
     */
    const COLUMN_KEY = 'tam_rank_links';

    /**
     * Snapshot memo for the posts-list column: read once per request, never
     * once per row (a per-row read would be an N+1 on every list load).
     *
     * @var array|null
     */
    private $column_snapshot = null;

    /**
     * Whether $column_snapshot has been resolved (null is a valid value).
     *
     * @var bool
     */
    private $column_snapshot_loaded = false;

    /**
     * Constructor: cron + AJAX wiring, self-healing weekly schedule
     * (same pattern as TamRank_Redirect_Suggestions).
     */
    public function __construct() {
        add_action( self::CRON_HOOK, array( $this, 'run_scan' ) );
        add_action( 'wp_ajax_tam_rank_link_graph_scan', array( $this, 'ajax_run_scan' ) );
        add_action( 'wp_ajax_tam_rank_link_graph_ignore', array( $this, 'ajax_toggle_ignore' ) );
        add_action( 'load-edit.php', array( $this, 'register_list_columns' ) );

        if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
            wp_schedule_event( time(), 'weekly', self::CRON_HOOK );
        }
    }

    /* ---------------------------------------------------------------------
     * Posts-list "Links" column
     * ------------------------------------------------------------------- */

    /**
     * Register the column on the audit post types only. Runs on the posts-list
     * screen, so nothing is hooked on any other admin page.
     */
    public function register_list_columns() {
        $screen = get_current_screen();
        if ( ! $screen || empty( $screen->post_type ) ) {
            return;
        }

        $slugs = class_exists( 'TamRank_Content_Type_Config' )
            ? TamRank_Content_Type_Config::get_enabled_audit_slugs()
            : array( 'post', 'page' );
        if ( ! in_array( $screen->post_type, $slugs, true ) ) {
            return;
        }

        add_filter( "manage_{$screen->post_type}_posts_columns", array( $this, 'add_list_column' ) );
        add_action( "manage_{$screen->post_type}_posts_custom_column", array( $this, 'render_list_column' ), 10, 2 );
    }

    /**
     * Add "Links" just before the Date column (appended when there is none).
     *
     * @param array $columns Column key => label.
     * @return array
     */
    public function add_list_column( $columns ) {
        $label = tamrank__( 'links_column', 'Links' );
        if ( ! isset( $columns['date'] ) ) {
            $columns[ self::COLUMN_KEY ] = $label;
            return $columns;
        }

        $reordered = array();
        foreach ( $columns as $key => $value ) {
            if ( 'date' === $key ) {
                $reordered[ self::COLUMN_KEY ] = $label;
            }
            $reordered[ $key ] = $value;
        }
        return $reordered;
    }

    /**
     * Render one cell straight from the snapshot — no scanning, no query.
     * A post outside the last snapshot (never scanned, added since, or an
     * audit exclusion) gets a dash rather than a misleading zero.
     *
     * @param string $column  Column key being rendered.
     * @param int    $post_id Post ID for this row.
     */
    public function render_list_column( $column, $post_id ) {
        if ( self::COLUMN_KEY !== $column ) {
            return;
        }

        $snapshot = $this->get_column_snapshot();
        $post_id  = (int) $post_id;
        $inbound  = ( $snapshot && isset( $snapshot['inbound_counts'][ $post_id ] ) ) ? (int) $snapshot['inbound_counts'][ $post_id ] : null;
        $outbound = ( $snapshot && isset( $snapshot['outbound_counts'][ $post_id ] ) ) ? (int) $snapshot['outbound_counts'][ $post_id ] : null;

        if ( null === $inbound || null === $outbound ) {
            printf(
                '<span aria-hidden="true">&mdash;</span><span class="screen-reader-text">%s</span>',
                esc_html( tamrank__( 'links_column_no_data', 'Not scanned yet' ) )
            );
            return;
        }

        printf(
            '<span class="tr-link-counter" title="%1$s">&darr; %2$s / &uarr; %3$s</span>',
            esc_attr(
                sprintf(
                    /* translators: 1: incoming internal links, 2: outgoing internal links */
                    tamrank__( 'links_column_hint', '%1$d incoming, %2$d outgoing internal links' ),
                    $inbound,
                    $outbound
                )
            ),
            esc_html( number_format_i18n( $inbound ) ),
            esc_html( number_format_i18n( $outbound ) )
        );
    }

    /**
     * Snapshot for the column, resolved at most once per request.
     *
     * @return array|null
     */
    private function get_column_snapshot() {
        if ( ! $this->column_snapshot_loaded ) {
            $this->column_snapshot        = self::get_snapshot();
            $this->column_snapshot_loaded = true;
        }
        return $this->column_snapshot;
    }

    /* ---------------------------------------------------------------------
     * Read API (static: dashboard / score provider read without an instance)
     * ------------------------------------------------------------------- */

    /**
     * Latest scan snapshot, or null when never scanned.
     *
     * Shape: { generated_at, total_posts, orphans[], weak[],
     *          inbound_counts{id:n}, outbound_counts{id:n}, scan_version,
     *          truncated }
     *
     * @return array|null
     */
    public static function get_snapshot() {
        $data = get_option( self::OPTION_KEY );
        if ( ! is_array( $data ) || empty( $data['generated_at'] ) ) {
            return null;
        }
        return $data;
    }

    /**
     * Post IDs with 0 inbound internal links, minus the ones the user ignored.
     *
     * Callers that need the unfiltered list (the audit table, which renders the
     * ignored rows hidden so they can be restored) read the snapshot directly.
     *
     * @return int[]
     */
    public static function get_orphans() {
        $data = self::get_snapshot();
        $ids  = ( $data && isset( $data['orphans'] ) && is_array( $data['orphans'] ) ) ? $data['orphans'] : array();
        return self::strip_ignored( $ids );
    }

    /**
     * Post IDs with 1..WEAK_THRESHOLD-1 inbound internal links, minus ignored.
     *
     * @return int[]
     */
    public static function get_weak() {
        $data = self::get_snapshot();
        $ids  = ( $data && isset( $data['weak'] ) && is_array( $data['weak'] ) ) ? $data['weak'] : array();
        return self::strip_ignored( $ids );
    }

    /**
     * Post IDs the user parked out of the orphan list.
     *
     * Stored separately from the scan snapshot, so a rescan never loses them
     * and ignoring never needs one.
     *
     * @return int[]
     */
    public static function get_ignored() {
        $ids = get_option( self::IGNORED_OPTION_KEY, array() );
        if ( ! is_array( $ids ) ) {
            return array();
        }
        return array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) );
    }

    /**
     * Drop ignored IDs from a list of flagged posts.
     *
     * @param int[] $ids Flagged post IDs.
     * @return int[]
     */
    private static function strip_ignored( $ids ) {
        $ignored = self::get_ignored();
        if ( empty( $ignored ) || empty( $ids ) ) {
            return $ids;
        }
        return array_values( array_diff( $ids, $ignored ) );
    }

    /**
     * Inbound count for one post, or null when the post was not in the scan.
     *
     * @param int $post_id Post ID.
     * @return int|null
     */
    public static function get_inbound_count( $post_id ) {
        $data = self::get_snapshot();
        if ( ! $data || ! isset( $data['inbound_counts'] ) || ! is_array( $data['inbound_counts'] ) ) {
            return null;
        }
        $post_id = (int) $post_id;
        return isset( $data['inbound_counts'][ $post_id ] ) ? (int) $data['inbound_counts'][ $post_id ] : null;
    }

    /**
     * Outbound count for one post, or null when the post was not in the scan.
     *
     * Counts unique internal link targets in the post's own content — the same
     * deduped href set the graph resolves from, so three links to one page
     * count once. External links and page-builder/menu links are not included
     * (see the v1 scope note at the top of this file).
     *
     * @param int $post_id Post ID.
     * @return int|null
     */
    public static function get_outbound_count( $post_id ) {
        $data = self::get_snapshot();
        if ( ! $data || ! isset( $data['outbound_counts'] ) || ! is_array( $data['outbound_counts'] ) ) {
            return null;
        }
        $post_id = (int) $post_id;
        return isset( $data['outbound_counts'][ $post_id ] ) ? (int) $data['outbound_counts'][ $post_id ] : null;
    }

    /* ---------------------------------------------------------------------
     * Scan
     * ------------------------------------------------------------------- */

    /**
     * Run a full scan and persist the snapshot.
     *
     * @return array|false Snapshot on success, false when another run holds the lock.
     */
    public function run_scan() {
        if ( get_transient( self::LOCK_TRANSIENT ) ) {
            return false;
        }
        set_transient( self::LOCK_TRANSIENT, 1, 10 * 60 );

        try {
            $snapshot = $this->build_graph();
            update_option( self::OPTION_KEY, $snapshot, false );
        } finally {
            delete_transient( self::LOCK_TRANSIENT );
        }

        return $snapshot;
    }

    /**
     * AJAX: on-demand scan from the audit page. Order: nonce → cap → work.
     */
    public function ajax_run_scan() {
        check_ajax_referer( 'tam_rank_nonce', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
        }

        $result = $this->run_scan();
        if ( false === $result ) {
            wp_send_json_error(
                array( 'message' => tamrank__( 'orphans_scan_locked', 'A scan is already running.' ) ),
                409
            );
        }

        wp_send_json_success(
            array(
                'orphans'   => count( $result['orphans'] ),
                'weak'      => count( $result['weak'] ),
                'total'     => (int) $result['total_posts'],
                'truncated' => ! empty( $result['truncated'] ),
            )
        );
    }

    /**
     * AJAX: park one page out of the orphan list, or put it back.
     *
     * Same gate as ajax_run_scan() and the audit exclusion handler: nonce
     * first, capability second, work third. Touches only
     * IGNORED_OPTION_KEY — the page keeps its place in every other part of
     * the audit, which is the whole reason this is not an audit exclusion.
     */
    public function ajax_toggle_ignore() {
        check_ajax_referer( 'tam_rank_nonce', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
        }

        $post_id = isset( $_POST['post_id'] ) ? absint( wp_unslash( $_POST['post_id'] ) ) : 0;
        if ( $post_id < 1 || null === get_post( $post_id ) ) {
            wp_send_json_error(
                array( 'message' => tamrank__( 'orphans_ignore_failed', 'Could not save that. Try again.' ) ),
                400
            );
        }

        $ignore  = isset( $_POST['ignore'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['ignore'] ) );
        $ignored = self::get_ignored();

        if ( $ignore ) {
            $ignored[] = $post_id;
            $ignored   = array_values( array_unique( $ignored ) );
        } else {
            $ignored = array_values( array_diff( $ignored, array( $post_id ) ) );
        }

        update_option( self::IGNORED_OPTION_KEY, $ignored, false );

        wp_send_json_success(
            array(
                'post_id' => $post_id,
                'ignored' => $ignore,
                'total'   => count( $ignored ),
            )
        );
    }

    /**
     * Build the inbound-link snapshot.
     *
     * Two passes: (1) enumerate all published audit posts batched, collecting
     * each post's normalized permalink + its outbound internal hrefs;
     * (2) resolve hrefs to target post IDs against the completed permalink map
     * (O(1), no DB) with url_to_postid() as memoized fallback, and count unique
     * source posts per target. Two passes are required: pass 1 must finish
     * before links to later-enumerated posts can resolve.
     *
     * @return array Snapshot.
     */
    private function build_graph() {
        $slugs = class_exists( 'TamRank_Content_Type_Config' )
            ? TamRank_Content_Type_Config::get_enabled_audit_slugs()
            : array();
        if ( empty( $slugs ) ) {
            $slugs = array( 'post', 'page' );
        }

        $site_host = $this->normalize_host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );

        $all_ids       = array();
        $permalink_map = array(); // normalized URL => post ID
        $outbound      = array(); // source ID => unique normalized internal hrefs
        $truncated     = false;
        $offset        = 0;

        do {
            $posts = get_posts(
                array(
                    'post_type'              => $slugs,
                    'post_status'            => 'publish',
                    'posts_per_page'         => self::BATCH_SIZE,
                    'offset'                 => $offset,
                    'orderby'                => 'ID',
                    'order'                  => 'ASC',
                    'update_post_meta_cache' => false,
                    'update_post_term_cache' => false,
                )
            );

            foreach ( $posts as $post ) {
                if ( count( $all_ids ) >= self::MAX_POSTS ) {
                    $truncated = true;
                    break 2;
                }
                $id             = (int) $post->ID;
                $all_ids[]      = $id;
                $permalink_key  = $this->normalize_url( get_permalink( $post ), $site_host );
                if ( '' !== $permalink_key ) {
                    $permalink_map[ $permalink_key ] = $id;
                }
                $outbound[ $id ] = $this->extract_internal_hrefs( $post->post_content, $site_host );
            }

            $offset += self::BATCH_SIZE;
        } while ( count( $posts ) === self::BATCH_SIZE );

        // Pass 2: resolve + count unique inbound sources per target.
        $inbound       = array_fill_keys( $all_ids, 0 );
        $resolve_cache = array();

        foreach ( $outbound as $source_id => $hrefs ) {
            $targets = array();
            foreach ( $hrefs as $href ) {
                $target_id = $this->resolve_target( $href, $site_host, $permalink_map, $resolve_cache );
                // Only audit-type targets count; a self-link is not an inbound link.
                if ( $target_id && $target_id !== $source_id && isset( $inbound[ $target_id ] ) ) {
                    $targets[ $target_id ] = true;
                }
            }
            foreach ( array_keys( $targets ) as $target_id ) {
                $inbound[ $target_id ]++;
            }
        }

        // Nav menus are link sources too. Each menu counts once per target it
        // points at (a page in both the header and the footer menu gets two),
        // the same unique-source-per-target rule the content pass uses. Posts
        // outside the candidate set are skipped by the isset() guard.
        $menu_inbound = $this->collect_menu_inbound( $site_host, $permalink_map, $resolve_cache );
        foreach ( $menu_inbound as $target_id => $menu_hits ) {
            if ( isset( $inbound[ $target_id ] ) ) {
                $inbound[ $target_id ] += $menu_hits;
            }
        }

        // Candidates = scanned posts minus pages that are never an orphan
        // problem (front page, posts page) minus audit exclusions. Excluded
        // posts still COUNT as link sources above — they only stop being
        // flagged themselves.
        $excluded = $this->get_excluded_candidate_ids( $slugs );

        $orphans         = array();
        $weak            = array();
        $inbound_counts  = array();
        $outbound_counts = array();
        $total           = 0;

        foreach ( $inbound as $id => $count ) {
            if ( isset( $excluded[ $id ] ) ) {
                continue;
            }
            $total++;
            $inbound_counts[ $id ]  = $count;
            $outbound_counts[ $id ] = isset( $outbound[ $id ] ) ? count( $outbound[ $id ] ) : 0;
            if ( 0 === $count ) {
                $orphans[] = $id;
            } elseif ( $count < self::WEAK_THRESHOLD ) {
                $weak[] = $id;
            }
        }

        return array(
            'generated_at'    => time(),
            'total_posts'     => $total,
            'orphans'         => $orphans,
            'weak'            => $weak,
            'inbound_counts'  => $inbound_counts,
            'outbound_counts' => $outbound_counts,
            'scan_version'    => 3,
            'truncated'       => $truncated,
        );
    }

    /**
     * IDs that must not be flagged orphan/weak: front page, posts page,
     * privacy policy page, manual audit exclusions, and (when the audit setting
     * says so) noindexed posts — mirrors
     * TamRank_Score_Provider::get_audit_exclusion_clauses().
     *
     * @param array $slugs Enabled audit post-type slugs.
     * @return array<int,true> Keyed by post ID.
     */
    private function get_excluded_candidate_ids( $slugs ) {
        $excluded = array();

        $front = (int) get_option( 'page_on_front' );
        if ( $front > 0 ) {
            $excluded[ $front ] = true;
        }
        $posts_page = (int) get_option( 'page_for_posts' );
        if ( $posts_page > 0 ) {
            $excluded[ $posts_page ] = true;
        }

        // WordPress itself knows which page is the privacy policy. Core links
        // it from the login and registration screens, not from content, so
        // flagging it as an orphan was always a false positive.
        $privacy_page = (int) get_option( 'wp_page_for_privacy_policy' );
        if ( $privacy_page > 0 ) {
            $excluded[ $privacy_page ] = true;
        }

        $manual = get_option( 'tam_rank_audit_excluded_posts', array() );
        if ( is_array( $manual ) ) {
            foreach ( $manual as $id ) {
                $excluded[ (int) $id ] = true;
            }
        }

        $general       = get_option( 'tam_rank_general_settings', array() );
        $excl_noindex  = isset( $general['audit_exclude_noindex'] ) ? (bool) $general['audit_exclude_noindex'] : true;
        if ( $excl_noindex ) {
            $noindex_ids = get_posts(
                array(
                    'post_type'              => $slugs,
                    'post_status'            => 'publish',
                    'posts_per_page'         => -1,
                    'fields'                 => 'ids',
                    'update_post_meta_cache' => false,
                    'update_post_term_cache' => false,
                    'meta_key'               => '_tam_rank_noindex', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- bounded admin/cron scan, mirrors the score provider's noindex exclusion
                    'meta_value'             => '1',                 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
                )
            );
            foreach ( $noindex_ids as $id ) {
                $excluded[ (int) $id ] = true;
            }
        }

        return $excluded;
    }

    /**
     * Inbound links contributed by the site's nav menus.
     *
     * Every registered menu counts as one source, whether or not it is
     * currently assigned to a theme location: an unassigned menu is still
     * routinely rendered by a widget or a shortcode, and guessing wrong here
     * would reintroduce exactly the false orphan this method removes. Items of
     * type post_type resolve straight to their object_id; other items go
     * through the same normalizer as content links, so /page/ and
     * https://www.site.com/page land on one target. Taxonomy items are skipped
     * — the graph only scores posts.
     *
     * Not covered: a block theme's navigation block, which stores its links in
     * a wp_navigation post rather than a nav_menu term.
     *
     * @param string $site_host     Normalized site host.
     * @param array  $permalink_map normalized URL => post ID.
     * @param array  $cache         Memo shared with the content pass (by ref).
     * @return array<int,int> Post ID => number of menus linking to it.
     */
    private function collect_menu_inbound( $site_host, $permalink_map, &$cache ) {
        $counts = array();
        if ( ! function_exists( 'wp_get_nav_menus' ) || ! function_exists( 'wp_get_nav_menu_items' ) ) {
            return $counts;
        }

        $menus = wp_get_nav_menus();
        if ( is_wp_error( $menus ) || empty( $menus ) ) {
            return $counts;
        }

        foreach ( $menus as $menu ) {
            $items = wp_get_nav_menu_items( $menu, array( 'update_post_term_cache' => false ) );
            if ( is_wp_error( $items ) || empty( $items ) ) {
                continue;
            }

            $targets = array();
            foreach ( $items as $item ) {
                $type   = isset( $item->type ) ? $item->type : '';
                $target = 0;

                if ( 'post_type' === $type ) {
                    $target = isset( $item->object_id ) ? (int) $item->object_id : 0;
                } elseif ( 'taxonomy' !== $type && ! empty( $item->url ) ) {
                    $normalized = $this->normalize_url( $item->url, $site_host );
                    if ( '' !== $normalized ) {
                        $target = $this->resolve_target( $normalized, $site_host, $permalink_map, $cache );
                    }
                }

                if ( $target > 0 ) {
                    $targets[ $target ] = true; // dedupe within one menu
                }
            }

            foreach ( array_keys( $targets ) as $target_id ) {
                $counts[ $target_id ] = isset( $counts[ $target_id ] ) ? $counts[ $target_id ] + 1 : 1;
            }
        }

        return $counts;
    }

    /* ---------------------------------------------------------------------
     * URL handling
     * ------------------------------------------------------------------- */

    /**
     * Pull unique internal href values out of post HTML.
     *
     * Lighter sibling of TamRank_Content_Analyzer::extract_links(): hrefs only,
     * no anchors/spam scoring, same internal/external classification rules.
     *
     * @param string $content   Raw post_content.
     * @param string $site_host Normalized site host.
     * @return string[] Normalized internal URLs (deduped).
     */
    private function extract_internal_hrefs( $content, $site_host ) {
        if ( null === $content || '' === $content ) {
            return array();
        }

        preg_match_all( '/<a\s[^>]*href=["\']([^"\']+)["\']/i', (string) $content, $matches );
        if ( empty( $matches[1] ) ) {
            return array();
        }

        $internal = array();
        foreach ( $matches[1] as $url ) {
            $url = trim( $url );
            if ( '' === $url
                || 0 === strpos( $url, '#' )
                || 0 === strpos( $url, 'javascript:' )
                || 0 === strpos( $url, 'mailto:' )
                || 0 === strpos( $url, 'tel:' ) ) {
                continue;
            }
            $normalized = $this->normalize_url( $url, $site_host );
            if ( '' !== $normalized ) {
                $internal[ $normalized ] = true; // dedupe per source
            }
        }

        return array_keys( $internal );
    }

    /**
     * Normalize a URL to a comparable "host/path?query" key (scheme-, www-,
     * fragment- and trailing-slash-insensitive). Returns '' for external or
     * unusable URLs, so '' doubles as the "not internal" signal.
     *
     * @param string $url       Href as found (absolute, protocol- or root-relative).
     * @param string $site_host Normalized site host.
     * @return string
     */
    private function normalize_url( $url, $site_host ) {
        $url = trim( (string) $url );
        if ( '' === $url ) {
            return '';
        }

        $hash = strpos( $url, '#' );
        if ( false !== $hash ) {
            $url = substr( $url, 0, $hash );
            if ( '' === $url ) {
                return '';
            }
        }

        if ( 0 === strpos( $url, '//' ) ) {
            $url = 'https:' . $url;
        } elseif ( 0 === strpos( $url, '/' ) ) {
            // Root-relative → always this site.
            $path = $url;
            return $site_host . $this->normalize_path_query( $path );
        }

        $parts = wp_parse_url( $url );
        if ( empty( $parts['host'] ) ) {
            // Relative path without leading slash (rare in stored content):
            // base is ambiguous, skip rather than guess.
            return '';
        }

        $host = $this->normalize_host( $parts['host'] );
        if ( $host !== $site_host ) {
            return ''; // external
        }

        $path  = isset( $parts['path'] ) ? $parts['path'] : '';
        $query = isset( $parts['query'] ) ? '?' . $parts['query'] : '';

        return $host . $this->normalize_path_query( $path . $query );
    }

    /**
     * Trailing-slash-insensitive path+query (query untouched).
     *
     * @param string $path_query Path, optionally with ?query appended.
     * @return string
     */
    private function normalize_path_query( $path_query ) {
        $q     = '';
        $qmark = strpos( $path_query, '?' );
        if ( false !== $qmark ) {
            $q          = substr( $path_query, $qmark );
            $path_query = substr( $path_query, 0, $qmark );
        }
        $path_query = rtrim( $path_query, '/' );
        return $path_query . $q;
    }

    /**
     * Lowercase host without www. (same rules as the content analyzer).
     *
     * @param string $host Hostname.
     * @return string
     */
    private function normalize_host( $host ) {
        $host = strtolower( (string) $host );
        if ( 0 === strpos( $host, 'www.' ) ) {
            $host = substr( $host, 4 );
        }
        return $host;
    }

    /**
     * Resolve a normalized internal URL to a post ID. Permalink map first
     * (exact, covers CPTs, no DB); url_to_postid() as memoized fallback for
     * alternate URL forms (?p=123, attachment-style, etc.). 0 = no target.
     *
     * @param string $normalized    Normalized URL key.
     * @param string $site_host     Normalized site host.
     * @param array  $permalink_map normalized URL => post ID.
     * @param array  $cache         Memo for url_to_postid() lookups (by ref).
     * @return int
     */
    private function resolve_target( $normalized, $site_host, $permalink_map, &$cache ) {
        if ( isset( $permalink_map[ $normalized ] ) ) {
            return $permalink_map[ $normalized ];
        }

        if ( isset( $cache[ $normalized ] ) ) {
            return $cache[ $normalized ];
        }

        // Rebuild an absolute URL in the site's own scheme/host formatting —
        // url_to_postid() is strict about matching home_url().
        $path                  = substr( $normalized, strlen( $site_host ) );
        $absolute              = home_url( $path );
        $cache[ $normalized ]  = (int) url_to_postid( $absolute );

        return $cache[ $normalized ];
    }
}
