<?php

namespace WPSN\SEO;

use WPSN\Core\Logger;
use WPSN\Core\Settings;
use WPSN\Domain\SeoDocumentContext;

defined('ABSPATH') || exit;

/**
 * Resolve user-provided custom JSON-LD snippets/templates for the current document.
 *
 * Supported inputs:
 * - a single JSON-LD node object ({"@type":"FAQPage", ...})
 * - an array of JSON-LD node objects ([{"@type":"FAQPage"}, ...])
 * - a full graph object ({"@context":"https://schema.org","@graph":[...]})
 *
 * Sources are appended in order:
 * 1) post-type template from Settings → Schema
 * 2) per-post override from the Schema metabox
 */
final class SchemaCustomJsonLdResolver
{
    private Settings $settings;

    private TemplateEngine $templates;

    private Logger $logger;

    public function __construct(Settings $settings, TemplateEngine $templates, Logger $logger)
    {
        $this->settings = $settings;
        $this->templates = $templates;
        $this->logger = $logger;
    }

    /**
     * @return array{nodes: array<int,array<string,mixed>>, errors: array<int,array<string,string>>}
     */
    public function resolve(SeoDocumentContext $ctx): array
    {
        $nodes = [];
        $errors = [];

        foreach ($this->collectSources($ctx) as $source) {
            $parsed = $this->parseSource((string) ($source['raw'] ?? ''), $ctx, (string) ($source['id'] ?? 'custom_json_ld'));
            if (!empty($parsed['nodes'])) {
                $nodes = array_merge($nodes, $parsed['nodes']);
            }
            if (!empty($parsed['errors'])) {
                $errors = array_merge($errors, $parsed['errors']);
            }
        }

        return [
            'nodes' => $nodes,
            'errors' => $errors,
        ];
    }

    public static function sanitizeRaw(string $raw): string
    {
        $raw = str_replace("\0", '', $raw);
        $raw = str_replace(["\r\n", "\r"], "\n", $raw);
        return trim($raw);
    }

    /**
     * @return array<int,array{id:string,raw:string}>
     */
    private function collectSources(SeoDocumentContext $ctx): array
    {
        $sources = [];

        if (!$ctx->isSingular()) {
            return $sources;
        }

        $postId = (int) $ctx->objectId;
        if ($postId <= 0) {
            return $sources;
        }

        $postType = $ctx->objectSubType !== null && $ctx->objectSubType !== ''
            ? (string) $ctx->objectSubType
            : (string) get_post_type($postId);

        if ($postType !== '' && $postType !== 'attachment') {
            $template = (string) $this->settings->get('schema.custom_json_ld.post_type_templates.' . $postType, '');
            $template = self::sanitizeRaw($template);
            if ($template !== '') {
                $sources[] = [
                    'id' => 'post_type:' . $postType,
                    'raw' => $template,
                ];
            }
        }

        $custom = (string) get_post_meta($postId, MetaKeys::SCHEMA_CUSTOM_JSON_LD, true);
        $custom = self::sanitizeRaw($custom);
        if ($custom !== '') {
            $sources[] = [
                'id' => 'post:' . $postId,
                'raw' => $custom,
            ];
        }

        return $sources;
    }

    /**
     * @return array{nodes: array<int,array<string,mixed>>, errors: array<int,array<string,string>>}
     */
    private function parseSource(string $raw, SeoDocumentContext $ctx, string $sourceId): array
    {
        $raw = self::sanitizeRaw($raw);
        if ($raw === '') {
            return ['nodes' => [], 'errors' => []];
        }

        $rendered = $this->renderTemplateTokens($raw, $ctx);
        $decoded = json_decode($rendered, true);

        if (!is_array($decoded)) {
            $message = function_exists('json_last_error_msg') ? (string) json_last_error_msg() : __('Invalid JSON.', 'wp-seo-next');
            return [
                'nodes' => [],
                'errors' => [[
                    'source' => $sourceId,
                    'label' => $this->sourceLabel($sourceId),
                    'message' => $message,
                ]],
            ];
        }

        $nodes = $this->normalizeDecodedToNodes($decoded);
        if (empty($nodes)) {
            return [
                'nodes' => [],
                'errors' => [[
                    'source' => $sourceId,
                    'label' => $this->sourceLabel($sourceId),
                    'message' => __('Valid JSON, but no JSON-LD nodes with @type/@graph were found.', 'wp-seo-next'),
                ]],
            ];
        }

        return [
            'nodes' => $nodes,
            'errors' => [],
        ];
    }

    private function renderTemplateTokens(string $template, SeoDocumentContext $ctx): string
    {
        if (strpos($template, '%') === false) {
            return $template;
        }

        $rendered = preg_replace_callback('/%([^%]+)%/u', function (array $m) use ($ctx): string {
            $expr = isset($m[1]) ? trim((string) $m[1]) : '';
            if ($expr === '') {
                return isset($m[0]) ? (string) $m[0] : '';
            }

            try {
                return $this->templates->render('%' . $expr . '%', $ctx);
            } catch (\Throwable $e) {
                $this->logger->warning('schema_custom_json_ld_token_failed', [
                    'expr' => $expr,
                    'message' => $e->getMessage(),
                ]);
                return '';
            }
        }, $template);

        return is_string($rendered) ? $rendered : $template;
    }

    /**
     * @param array<mixed> $decoded
     * @return array<int,array<string,mixed>>
     */
    private function normalizeDecodedToNodes(array $decoded): array
    {
        if ($this->isList($decoded)) {
            $nodes = [];
            foreach ($decoded as $item) {
                if (!is_array($item)) {
                    continue;
                }
                $nodes = array_merge($nodes, $this->normalizeObjectOrGraph($item));
            }
            return $nodes;
        }

        return $this->normalizeObjectOrGraph($decoded);
    }

    /**
     * @param array<string,mixed> $item
     * @return array<int,array<string,mixed>>
     */
    private function normalizeObjectOrGraph(array $item): array
    {
        if (isset($item['@graph']) && is_array($item['@graph'])) {
            $nodes = [];
            foreach ($item['@graph'] as $node) {
                if (!is_array($node)) {
                    continue;
                }
                $normalized = $this->normalizeNode($node);
                if (!empty($normalized)) {
                    $nodes[] = $normalized;
                }
            }
            return $nodes;
        }

        $normalized = $this->normalizeNode($item);
        return !empty($normalized) ? [$normalized] : [];
    }

    /**
     * @param array<string,mixed> $node
     * @return array<string,mixed>
     */
    private function normalizeNode(array $node): array
    {
        $type = isset($node['@type']) ? trim((string) $node['@type']) : '';
        if ($type === '') {
            return [];
        }

        if (isset($node['@context'])) {
            unset($node['@context']);
        }

        return $node;
    }

    /**
     * @param array<mixed> $array
     */
    private function isList(array $array): bool
    {
        if (function_exists('array_is_list')) {
            return array_is_list($array);
        }

        $i = 0;
        foreach (array_keys($array) as $key) {
            if ($key !== $i) {
                return false;
            }
            $i++;
        }

        return true;
    }

    private function sourceLabel(string $sourceId): string
    {
        if (strpos($sourceId, 'post_type:') === 0) {
            return sprintf(__('Post type template: %s', 'wp-seo-next'), substr($sourceId, 10));
        }

        if (strpos($sourceId, 'post:') === 0) {
            return __('Per-post custom JSON-LD', 'wp-seo-next');
        }

        return __('Custom JSON-LD', 'wp-seo-next');
    }
}
