<?php

namespace WPSN\SEO;

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

defined('ABSPATH') || exit;

/**
 * VariableResolver
 *
 * Resolves dynamic template tokens like:
 * - %meta:key%
 * - %opt:option_name%
 * - %siteopt:option_name%
 * - %user:meta_key%
 * - %acf:field% (via providers filter)
 *
 * New developer API:
 * - filter: wpsn/variables/resolve
 *   Return:
 *    - null => not handled
 *    - string => handled (non-empty)
 *    - array{handled:bool,value:string} => handled even when value is empty
 *
 * Back-compat:
 * - filter: wpsn/template_dynamic_variable
 */
final class VariableResolver
{
    private Settings $settings;

    public function __construct(Settings $settings)
    {
        $this->settings = $settings;
    }

    /**
     * @return array{handled:bool,value:string,source:string}
     */
    public function resolve(string $prefix, string $key, SeoDocumentContext $ctx): array
    {
        $prefix = strtolower(trim((string) $prefix));
        $key = trim((string) $key);
        if ($prefix === '' || $key === '') {
            return ['handled' => false, 'value' => '', 'source' => ''];
        }

        $prefix = $this->normalizePrefix($prefix);

        // 1) New API: allow custom providers to override/handle.
        $filtered = apply_filters('wpsn/variables/resolve', null, $prefix, $key, $ctx);
        $parsed = $this->parseFilterResult($filtered);
        if ($parsed['handled']) {
            $parsed['source'] = 'filter:wpsn/variables/resolve';
            return $parsed;
        }

        // 2) Core providers.
        switch ($prefix) {
            case 'meta':
                return [
                    'handled' => true,
                    'value' => $this->resolveNativeMeta($ctx, $key),
                    'source' => 'core:meta',
                ];

            case 'opt':
            case 'option':
                return [
                    'handled' => true,
                    'value' => $this->resolveOption($key, false),
                    'source' => 'core:option',
                ];

            case 'siteopt':
            case 'site_option':
            case 'network_option':
                return [
                    'handled' => true,
                    'value' => $this->resolveOption($key, true),
                    'source' => 'core:site_option',
                ];

            case 'user':
            case 'usermeta':
            case 'author':
                return [
                    'handled' => true,
                    'value' => $this->resolveUserMeta($ctx, $key),
                    'source' => 'core:user_meta',
                ];
        }

        // 3) Legacy API (best-effort): allow older integrations.
        // Note: legacy filter cannot reliably signal "handled but empty", so we only treat non-empty as handled.
        $legacy = apply_filters('wpsn/template_dynamic_variable', '', $prefix, $key, $ctx);
        $legacyParsed = $this->parseLegacyResult($legacy);
        if ($legacyParsed['handled']) {
            $legacyParsed['source'] = 'filter:wpsn/template_dynamic_variable';
            return $legacyParsed;
        }

        return ['handled' => false, 'value' => '', 'source' => ''];
    }

    private function normalizePrefix(string $prefix): string
    {
        $p = strtolower(trim($prefix));
        if (in_array($p, ['acf_group', 'acf-group', 'acfgroup'], true)) {
            return 'acf';
        }
        if (in_array($p, ['metabox', 'meta_box', 'meta-box'], true)) {
            return 'mb';
        }
        if (in_array($p, ['jetengine', 'jet-engine', 'jet_engine'], true)) {
            return 'jet';
        }
        if ($p === 'field') {
            return 'cf';
        }
        if (in_array($p, ['options', 'opts'], true)) {
            return 'option';
        }
        if (in_array($p, ['siteopt', 'siteopt', 'networkopt', 'network'], true)) {
            return 'site_option';
        }
        return $p;
    }

    /**
     * Parse new provider filter return.
     *
     * @param mixed $r
     * @return array{handled:bool,value:string,source:string}
     */
    private function parseFilterResult($r): array
    {
        if (is_array($r)) {
            $handled = !empty($r['handled']);
            $value = isset($r['value']) ? (string) $r['value'] : '';
            return ['handled' => (bool) $handled, 'value' => trim($value), 'source' => ''];
        }

        if (is_string($r)) {
            $v = trim($r);
            if ($v === '') {
                return ['handled' => false, 'value' => '', 'source' => ''];
            }
            return ['handled' => true, 'value' => $v, 'source' => ''];
        }

        return ['handled' => false, 'value' => '', 'source' => ''];
    }

    /**
     * Parse legacy filter return.
     *
     * @param mixed $r
     * @return array{handled:bool,value:string,source:string}
     */
    private function parseLegacyResult($r): array
    {
        if (is_array($r) && array_key_exists('handled', $r)) {
            $handled = !empty($r['handled']);
            $value = isset($r['value']) ? (string) $r['value'] : '';
            return ['handled' => (bool) $handled, 'value' => trim($value), 'source' => ''];
        }

        if (is_string($r)) {
            $v = trim($r);
            if ($v === '') {
                return ['handled' => false, 'value' => '', 'source' => ''];
            }
            return ['handled' => true, 'value' => $v, 'source' => ''];
        }

        return ['handled' => false, 'value' => '', 'source' => ''];
    }

    private function resolveNativeMeta(SeoDocumentContext $ctx, string $key): string
    {
        if ($ctx->isSingular()) {
            $raw = get_post_meta((int) $ctx->objectId, $key, true);
            return $this->stringify($raw);
        }

        if ($ctx->isTerm()) {
            $raw = get_term_meta((int) $ctx->objectId, $key, true);
            return $this->stringify($raw);
        }

        return '';
    }

    /**
     * Resolve an option value.
     *
     * Supports nested keys via colon paths:
     * - %opt:my_option:subkey%
     */
    private function resolveOption(string $key, bool $network): string
    {
        [$rootKey, $path] = $this->splitPathKey($key);

        $raw = null;
        if ($network && function_exists('get_site_option')) {
            $raw = get_site_option($rootKey, null);
            // If not multisite or option missing, fall back to normal option.
            if ($raw === null && function_exists('get_option')) {
                $raw = get_option($rootKey, null);
            }
        } else {
            $raw = function_exists('get_option') ? get_option($rootKey, null) : null;
        }

        if (!empty($path)) {
            $vals = $this->extractValuesByPath($raw, $path);
            if (count($vals) === 1) {
                $raw = $vals[0];
            } elseif (count($vals) > 1) {
                $raw = $vals;
            } else {
                $raw = '';
            }
        }

        return $this->stringify($raw);
    }

    /**
     * Resolve user meta for the current author (singular/author archive).
     *
     * Supports nested keys via colon paths (if value is array).
     */
    private function resolveUserMeta(SeoDocumentContext $ctx, string $key): string
    {
        $userId = $this->resolveUserIdForContext($ctx);
        if ($userId <= 0) {
            return '';
        }

        [$rootKey, $path] = $this->splitPathKey($key);
        $raw = get_user_meta($userId, $rootKey, true);

        if (!empty($path)) {
            $vals = $this->extractValuesByPath($raw, $path);
            if (count($vals) === 1) {
                $raw = $vals[0];
            } elseif (count($vals) > 1) {
                $raw = $vals;
            } else {
                $raw = '';
            }
        }

        return $this->stringify($raw);
    }

    private function resolveUserIdForContext(SeoDocumentContext $ctx): int
    {
        // Author archive.
        if ($ctx->type === SeoDocumentContext::TYPE_ARCHIVE && $ctx->objectSubType === 'author' && $ctx->objectId !== null) {
            $aid = (int) $ctx->objectId;
            return $aid > 0 ? $aid : 0;
        }

        // Singular: use post author.
        if ($ctx->isSingular()) {
            $p = get_post((int) $ctx->objectId);
            if ($p instanceof \WP_Post) {
                $aid = (int) $p->post_author;
                return $aid > 0 ? $aid : 0;
            }
        }

        return 0;
    }

    /**
     * Split a colon-path key into root key + path segments.
     *
     * @return array{0:string,1:array<int,string>}
     */
    private function splitPathKey(string $key): array
    {
        $key = trim((string) $key);
        if ($key === '' || strpos($key, ':') === false) {
            return [$key, []];
        }

        $parts = explode(':', $key);
        $parts = array_values(array_filter(array_map('trim', $parts), static fn($v) => $v !== ''));
        if (count($parts) <= 1) {
            return [$key, []];
        }

        $root = (string) array_shift($parts);
        return [$root, $parts];
    }

    /**
     * Extract values by path segments from (possibly nested) arrays.
     *
     * @param mixed $value
     * @param array<int,string> $path
     * @return array<int,mixed>
     */
    private function extractValuesByPath($value, array $path): array
    {
        if (empty($path)) {
            return [$value];
        }

        $seg = (string) array_shift($path);
        if ($seg === '') {
            return $this->extractValuesByPath($value, $path);
        }

        // List: apply to each element.
        if (is_array($value) && $this->isListArray($value)) {
            $out = [];
            foreach ($value as $row) {
                $vals = $this->extractValuesByPath($row, array_merge([$seg], $path));
                foreach ($vals as $v) {
                    $out[] = $v;
                }
            }
            return $out;
        }

        // Assoc array: drill down.
        if (is_array($value)) {
            if (array_key_exists($seg, $value)) {
                return $this->extractValuesByPath($value[$seg], $path);
            }
            return [];
        }

        // Object prop.
        if (is_object($value) && isset($value->{$seg})) {
            return $this->extractValuesByPath($value->{$seg}, $path);
        }

        return [];
    }

    /**
     * @param array<mixed> $arr
     */
    private function isListArray(array $arr): bool
    {
        $i = 0;
        foreach ($arr as $k => $_v) {
            if ($k !== $i) {
                return false;
            }
            $i++;
        }
        return true;
    }

    /**
     * Best-effort stringify for meta/option values.
     *
     * @param mixed $raw
     */
    private function stringify($raw): string
    {
        if ($raw === null) {
            return '';
        }

        if (is_string($raw)) {
            return trim($raw);
        }

        if (is_int($raw) || is_float($raw)) {
            return trim((string) $raw);
        }

        if (is_bool($raw)) {
            return $raw ? '1' : '0';
        }

        if (is_object($raw)) {
            if (method_exists($raw, '__toString')) {
                try {
                    return trim((string) $raw);
                } catch (\Throwable $e) {
                    return '';
                }
            }
            return '';
        }

        if (is_array($raw)) {
            $flat = [];
            $this->flattenArrayScalars($raw, $flat);
            $flat = array_values(array_filter(array_map('trim', array_map('strval', $flat)), static fn($v) => $v !== ''));
            if (empty($flat)) {
                return '';
            }
            // Avoid huge expansions.
            if (count($flat) > 20) {
                $flat = array_slice($flat, 0, 20);
            }
            return implode(', ', $flat);
        }

        return '';
    }

    /**
     * @param array<mixed> $arr
     * @param array<int,mixed> $out
     */
    private function flattenArrayScalars(array $arr, array &$out): void
    {
        foreach ($arr as $v) {
            if (is_array($v)) {
                $this->flattenArrayScalars($v, $out);
            } elseif (is_scalar($v) || $v === null) {
                $out[] = $v;
            }
        }
    }
}
