<?php

/**
 * Entity Deduplicator
 *
 * Site-wide deduplication of schema.org entities by stable @id.
 *
 * Unlike SchemaOutputManager which dedupes by @type on a single request,
 * this runs across the whole site collection and preserves entity identity:
 * the same author (Person) appearing on 40 articles collapses into a single
 * node, and the 40 articles keep their references intact.
 *
 * Dedupe policy:
 *   - Key on @id. Entities with the same @id are merged.
 *   - Missing @id: synthesise one via EntityIdBuilder::derive(), or fall back
 *     to a positional key so nothing is silently dropped.
 *   - Merge: first-seen wins for scalar properties, arrays are unioned (unique),
 *     nested objects recurse. Non-null beats null.
 *   - @type conflicts (same @id, different @type) keep the most specific
 *     known subtype; otherwise keep the first.
 *
 * @package ProRank\SEO\Core\Schema
 * @since   2.2.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Core\Schema;

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


class EntityDeduplicator {

    /**
     * Run dedupe on a list of schema entities.
     *
     * Input: flat list of schema arrays, each representing a single entity.
     * Output: deduped list, preserving insertion order of the first occurrence
     * of each @id.
     *
     * @param array $entities Flat list of entity arrays.
     * @param int   $context_post Optional post ID to disambiguate derived @ids.
     * @return array Deduplicated entities with stable @ids.
     */
    public function dedupe(array $entities, int $context_post = 0): array {
        $by_id = [];   // @id => merged entity
        $order = [];   // ordered list of @ids as first encountered
        $fallback_counter = 0;

        foreach ($entities as $entity) {
            if (!is_array($entity) || empty($entity)) {
                continue;
            }

            // Skip @context wrappers masquerading as entities.
            if (isset($entity['@graph']) && is_array($entity['@graph'])) {
                // Caller should have flattened already — unpack defensively.
                $flattened = $this->dedupe($entity['@graph'], $context_post);
                foreach ($flattened as $nested) {
                    $id = $nested['@id'] ?? null;
                    if (!is_string($id) || $id === '') {
                        continue;
                    }
                    if (!isset($by_id[$id])) {
                        $by_id[$id] = $nested;
                        $order[] = $id;
                    } else {
                        $by_id[$id] = $this->merge_entities($by_id[$id], $nested);
                    }
                }
                continue;
            }

            $id = EntityIdBuilder::derive($entity, $context_post);
            if ($id === null) {
                $fallback_counter++;
                $id = EntityIdBuilder::site_base_url() . '#unidentified-' . $fallback_counter;
            }

            // Attach the @id so downstream consumers see a resolved identifier.
            $entity['@id'] = $id;

            if (!isset($by_id[$id])) {
                $by_id[$id] = $entity;
                $order[] = $id;
                continue;
            }

            $by_id[$id] = $this->merge_entities($by_id[$id], $entity);
        }

        $result = [];
        foreach ($order as $id) {
            $result[] = $by_id[$id];
        }
        return $result;
    }

    /**
     * Merge two entity arrays that share an @id.
     *
     * - Scalar properties: first non-null value wins (preserves existing data).
     * - Indexed arrays (lists): unioned, duplicates removed by JSON-serialised
     *   value equality.
     * - Associative arrays (nested objects): recursed.
     * - @type: prefer existing unless the new value is a strict subtype string
     *   of the existing one (heuristic: longer string wins for now).
     *
     * @param array $a First-seen entity.
     * @param array $b Newer entity.
     * @return array
     */
    public function merge_entities(array $a, array $b): array {
        $merged = $a;

        foreach ($b as $key => $value) {
            if ($key === '@id') {
                continue;
            }

            if ($key === '@type') {
                $merged['@type'] = $this->pick_type($a['@type'] ?? null, $value);
                continue;
            }

            if (!array_key_exists($key, $merged) || $merged[$key] === null || $merged[$key] === '') {
                $merged[$key] = $value;
                continue;
            }

            if (is_array($merged[$key]) && is_array($value)) {
                if ($this->is_list($merged[$key]) && $this->is_list($value)) {
                    $merged[$key] = $this->union_lists($merged[$key], $value);
                } else {
                    $merged[$key] = $this->merge_entities($merged[$key], $value);
                }
                continue;
            }

            // Scalar on both sides: keep first, the existing one.
        }

        return $merged;
    }

    /**
     * Decide which @type to keep when two entities collide.
     *
     * @param mixed $existing Existing type (string, array, or null).
     * @param mixed $incoming Incoming type.
     * @return mixed
     */
    private function pick_type($existing, $incoming) {
        if ($existing === null || $existing === '') {
            return $incoming;
        }
        if ($incoming === null || $incoming === '') {
            return $existing;
        }

        if (is_string($existing) && is_string($incoming)) {
            // Prefer the more specific subtype (simple heuristic — longer wins).
            return strlen($incoming) > strlen($existing) ? $incoming : $existing;
        }

        // Arrays or mixed: union into an array of unique types.
        $types = array_unique(array_merge(
            is_array($existing) ? $existing : [$existing],
            is_array($incoming) ? $incoming : [$incoming]
        ));
        $types = array_values(array_filter($types, static fn($t) => is_string($t) && $t !== ''));
        if (count($types) === 1) {
            return $types[0];
        }
        return $types;
    }

    /**
     * Union two list-style arrays, removing duplicates by JSON identity.
     *
     * @param array $a First list.
     * @param array $b Second list.
     * @return array
     */
    private function union_lists(array $a, array $b): array {
        $seen = [];
        $result = [];
        foreach (array_merge($a, $b) as $item) {
            $key = is_scalar($item) ? (string) $item : (wp_json_encode($item, JSON_UNESCAPED_SLASHES) ?: '');
            if ($key === '' || isset($seen[$key])) {
                continue;
            }
            $seen[$key] = true;
            $result[] = $item;
        }
        return $result;
    }

    /**
     * True if the array looks like an indexed list (0..n-1 keys).
     *
     * @param array $array Array under test.
     * @return bool
     */
    private function is_list(array $array): bool {
        if ($array === []) {
            return true;
        }
        if (function_exists('array_is_list')) {
            return array_is_list($array);
        }
        $expected = 0;
        foreach ($array as $key => $_value) {
            if ($key !== $expected) {
                return false;
            }
            $expected++;
        }
        return true;
    }
}
