<?php

declare(strict_types=1);

final class MyronSearchTextExtractor
{
    private const MAX_DEPTH = 32;
    private const MIN_STRING_LENGTH = 2;

    private const NON_PROSE_KEYS = [
        'id', 'uuid', 'slug', 'href', 'url', 'src', 'image', 'icon', 'cta',
        'hero_image', 'featured_image', 'background_image', 'avatar', 'logo',
        'token', 'tokens', 'token_path', 'class', 'classes', 'className',
        'color', 'colour', 'variant', 'component_id', 'componentId',
        'section_key', 'sectionKey', 'kind', 'type', 'mic_code', 'micCode',
        'created_at', 'updated_at', 'published_at',
        'created_by', 'updated_by', 'role_scope', 'role_source_id',
        'template_id', 'template_key', 'template_class',
        'bundle_id', 'source_bundle_id', 'site_type',
        'video_url', 'embed_url', 'link', 'links', 'cta_href',
        'schema_org', 'schemaOrg', 'audience_class', 'audienceClass',
        'jsonld_mode', 'jsonld_type', 'jsonld_custom',
        'canonical_override', 'robots_override',
        'twitter_image', 'og_image',
    ];

    private MicElementRegistry $registry;
    private string $missLog;

    public function __construct(MicElementRegistry $registry)
    {
        $this->registry = $registry;
        $this->missLog = '';
    }

    public function extract(array $document): string
    {
        $this->missLog = '';
        $parts = [];
        $this->walk($document, 0, $parts);
        $text = implode(' ', array_filter($parts, static fn (string $p): bool => $p !== ''));
        return self::collapseWhitespace($text);
    }

    public function extractFromJson(string $contentJson): string
    {
        try {
            $decoded = json_decode($contentJson !== '' ? $contentJson : '{}', true, 512, JSON_THROW_ON_ERROR);
        } catch (Throwable) {
            return '';
        }
        if (!is_array($decoded)) {
            return '';
        }
        return $this->extract($decoded);
    }

    /**
     * @param mixed $node
     * @param list<string> $parts
     */
    private function walk($node, int $depth, array &$parts, ?string $parentKey = null): void
    {
        if ($depth > self::MAX_DEPTH) {
            return;
        }

        if (is_string($node)) {
            if ($parentKey !== null && $this->isNonProseKey($parentKey)) {
                return;
            }
            $value = self::cleanString($node);
            if ($value !== '' && self::looksLikeProse($value)) {
                $parts[] = $value;
            }
            return;
        }

        if (!is_array($node)) {
            return;
        }

        // Try MIC searchable contract for blocks that declare a kind/type + id.
        $kindCandidates = [
            (string) ($node['kind'] ?? ''),
            (string) ($node['type'] ?? ''),
            (string) ($node['mic_code'] ?? ''),
            (string) ($node['micCode'] ?? ''),
        ];
        foreach ($kindCandidates as $kind) {
            if ($kind === '') {
                continue;
            }
            $resolver = $this->registry->resolverFor($kind);
            if ($resolver instanceof MicElementSearchable) {
                try {
                    $contributed = $resolver->extractSearchText($node);
                    if (is_string($contributed) && $contributed !== '') {
                        $parts[] = self::collapseWhitespace($contributed);
                    }
                } catch (Throwable $e) {
                    $this->missLog .= 'mic:' . $kind . ':' . $e->getMessage() . "\n";
                }
                // Resolver handled this node; don't double-walk its internals.
                return;
            }
        }

        foreach ($node as $key => $child) {
            $childKey = is_string($key) ? $key : $parentKey;
            try {
                $this->walk($child, $depth + 1, $parts, $childKey);
            } catch (Throwable $e) {
                $this->missLog .= 'walk:' . (is_string($key) ? $key : '#') . ':' . $e->getMessage() . "\n";
            }
        }
    }

    private function isNonProseKey(string $key): bool
    {
        return in_array($key, self::NON_PROSE_KEYS, true);
    }

    private static function cleanString(string $value): string
    {
        $stripped = strip_tags(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
        return trim(self::collapseWhitespace($stripped));
    }

    private static function collapseWhitespace(string $value): string
    {
        $value = preg_replace('/[\s\x{00A0}]+/u', ' ', $value);
        return is_string($value) ? trim($value) : '';
    }

    private static function looksLikeProse(string $value): bool
    {
        if (strlen($value) < self::MIN_STRING_LENGTH) {
            return false;
        }
        if (preg_match('#^(https?:|mailto:|tel:|/)#i', $value) === 1) {
            return false;
        }
        if (preg_match('/^#[0-9a-f]{3,8}$/i', $value) === 1) {
            return false;
        }
        if (preg_match('/^[a-z0-9_]+[._-][a-z0-9_]+(?:[._-][a-z0-9_]+)*$/i', $value) === 1
            && !str_contains($value, ' ')
            && strlen($value) <= 64) {
            // looks like a slug / token / dotted identifier (requires at least one delimiter)
            return false;
        }
        // Pure numeric IDs / dates without prose context.
        if (preg_match('/^[0-9T:Z+\-]+$/', $value) === 1) {
            return false;
        }
        return true;
    }

    public function lastExtractionMissLog(): string
    {
        return $this->missLog;
    }
}

function myronSearchTextExtractor(): MyronSearchTextExtractor
{
    static $instance = null;
    if ($instance instanceof MyronSearchTextExtractor) {
        return $instance;
    }
    $instance = new MyronSearchTextExtractor(myronMicElementRegistry());
    return $instance;
}
