<?php

/**
 * Entity ID Builder
 *
 * Builds stable schema.org @id values so the same entity (site, organization,
 * person, page, article) always serialises with the same identifier across
 * the whole site. Stable @ids are the prerequisite for cross-page entity
 * deduplication and relationship linking (author -> article, org -> product).
 *
 * Format:
 *   {canonical_site_url}#{type}                     for site-scoped entities
 *   {canonical_site_url}#organization-{id}          for org variants
 *   {canonical_site_url}#person-{user_id}           for people
 *   {permalink}#{type}                              for per-post entities
 *
 * @package ProRank\SEO\Core\Schema
 * @since   2.2.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\Schema;

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


class EntityIdBuilder {

    /**
     * Cached canonical site URL (trailing slash, lowercased scheme/host).
     *
     * @var string|null
     */
    private static ?string $site_url = null;

    /**
     * Return the canonical site URL used as the base for every @id.
     *
     * Always trailing-slashed and lowercased on scheme/host to avoid
     * duplicate nodes caused by `https://EXAMPLE.com` vs `https://example.com/`.
     *
     * @return string
     */
    public static function site_base_url(): string {
        if (self::$site_url !== null) {
            return self::$site_url;
        }

        $home = home_url('/');
        $parts = wp_parse_url($home);
        if (is_array($parts)) {
            $scheme = strtolower($parts['scheme'] ?? 'https');
            $host = strtolower($parts['host'] ?? '');
            $port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
            $path = $parts['path'] ?? '/';
            if ($path === '' || substr($path, -1) !== '/') {
                $path .= '/';
            }
            $home = $scheme . '://' . $host . $port . $path;
        } else {
            $home = trailingslashit((string) $home);
        }

        self::$site_url = $home;
        return self::$site_url;
    }

    /**
     * @id for the WebSite entity.
     *
     * @return string
     */
    public static function website(): string {
        return self::site_base_url() . '#website';
    }

    /**
     * @id for the primary site-level Organization entity.
     *
     * @return string
     */
    public static function organization(): string {
        return self::site_base_url() . '#organization';
    }

    /**
     * @id for a Person entity.
     *
     * @param int $user_id WordPress user ID. Falls back to a site-level person node for 0.
     * @return string
     */
    public static function person(int $user_id = 0): string {
        if ($user_id > 0) {
            return self::site_base_url() . '#person-' . $user_id;
        }
        return self::site_base_url() . '#person';
    }

    /**
     * @id for the ImageObject that represents the site logo.
     *
     * @return string
     */
    public static function site_logo(): string {
        return self::site_base_url() . '#logo';
    }

    /**
     * @id for the SearchAction of the site.
     *
     * @return string
     */
    public static function search_action(): string {
        return self::site_base_url() . '#searchaction';
    }

    /**
     * @id for a post-scoped entity.
     *
     * @param int    $post_id Post ID.
     * @param string $type    Lowercased schema type suffix (webpage, article, breadcrumb, etc.).
     * @return string
     */
    public static function post(int $post_id, string $type = 'webpage'): string {
        $permalink = $post_id > 0 ? (string) get_permalink($post_id) : '';
        if ($permalink === '') {
            return self::site_base_url() . '#' . self::sanitize_fragment($type) . '-' . $post_id;
        }
        return self::ensure_trailing_slash($permalink) . '#' . self::sanitize_fragment($type);
    }

    /**
     * @id for the WebPage node of a post.
     *
     * @param int $post_id Post ID.
     * @return string
     */
    public static function webpage(int $post_id): string {
        return self::post($post_id, 'webpage');
    }

    /**
     * @id for the primary content entity (Article, BlogPosting, etc.) of a post.
     *
     * @param int    $post_id Post ID.
     * @param string $type    Content entity type (article, blogposting, product, event, ...).
     * @return string
     */
    public static function content(int $post_id, string $type = 'article'): string {
        return self::post($post_id, $type);
    }

    /**
     * @id for a BreadcrumbList attached to a post.
     *
     * @param int $post_id Post ID.
     * @return string
     */
    public static function breadcrumb(int $post_id): string {
        return self::post($post_id, 'breadcrumb');
    }

    /**
     * Normalise an arbitrary URL into a stable `@id` fragment base.
     *
     * Used when a schema payload references an external URL we have no better
     * identifier for. Not meant for post-scoped entities (use post() for those).
     *
     * @param string $url      Source URL.
     * @param string $fragment Fragment suffix (without #).
     * @return string
     */
    public static function for_url(string $url, string $fragment): string {
        $url = trim($url);
        if ($url === '') {
            return self::site_base_url() . '#' . self::sanitize_fragment($fragment);
        }
        $url = self::ensure_trailing_slash($url);
        return $url . '#' . self::sanitize_fragment($fragment);
    }

    /**
     * Derive a sensible @id for an already-rendered schema array.
     *
     * Order of preference: existing @id, url, name-based slug, type-based fallback.
     *
     * @param array  $schema       Schema payload.
     * @param int    $context_post Post ID context (0 if site-level).
     * @return string|null Stable @id, or null if it can't be computed without
     *                    falling back to a positional key (caller should handle).
     */
    public static function derive(array $schema, int $context_post = 0): ?string {
        if (isset($schema['@id']) && is_string($schema['@id']) && $schema['@id'] !== '') {
            return $schema['@id'];
        }

        $type = self::extract_primary_type($schema);
        if ($type === '') {
            return null;
        }

        $url = isset($schema['url']) && is_string($schema['url']) ? $schema['url'] : '';
        $fragment = strtolower($type);

        if ($url !== '') {
            return self::for_url($url, $fragment);
        }

        if ($context_post > 0) {
            return self::post($context_post, $fragment);
        }

        // Identify-by-name fallback for site-level nodes (Organization, Person).
        $name = isset($schema['name']) && is_string($schema['name']) ? $schema['name'] : '';
        if ($name !== '') {
            $slug = sanitize_title($name);
            if ($slug !== '') {
                return self::site_base_url() . '#' . $fragment . '-' . $slug;
            }
        }

        return self::site_base_url() . '#' . $fragment;
    }

    /**
     * Return the primary @type string for a schema payload.
     *
     * @param array $schema Schema data.
     * @return string Lowercased type or empty string.
     */
    private static function extract_primary_type(array $schema): string {
        $type = $schema['@type'] ?? '';
        if (is_array($type)) {
            $type = reset($type);
        }
        return is_string($type) ? strtolower($type) : '';
    }

    /**
     * Ensure a URL has a trailing slash before we append a fragment.
     *
     * @param string $url Input URL.
     * @return string
     */
    private static function ensure_trailing_slash(string $url): string {
        $url = rtrim($url, '#');
        $hash = strpos($url, '#');
        if ($hash !== false) {
            $url = substr($url, 0, $hash);
        }
        return trailingslashit($url);
    }

    /**
     * Sanitise a string for use as a fragment suffix.
     *
     * @param string $value Raw value.
     * @return string
     */
    private static function sanitize_fragment(string $value): string {
        $value = strtolower($value);
        $value = preg_replace('/[^a-z0-9\-_]/', '-', $value) ?? '';
        $value = trim($value, '-');
        return $value !== '' ? $value : 'entity';
    }
}
