<?php

namespace WPSN\Modules\Redirects;

use WPSN\Core\Logger;

defined('ABSPATH') || exit;

/**
 * Builds redirect suggestions for 404 URLs.
 *
 * Designed to be used on-demand (admin action / AJAX) to avoid heavy work on page load.
 */
final class RedirectSuggestionService
{
    private Logger $logger;

    public function __construct(Logger $logger)
    {
        $this->logger = $logger;
    }

    /**
     * @return array<int, array{to: string, label: string, reason: string, score: float}>
     */
    public function suggestForLoggedUrl(string $loggedUrl, int $limit = 5): array
    {
        $path = RedirectsUrl::normalizeFrom($loggedUrl);
        if ($path === '' || $path === '/') {
            return [];
        }

        $limit = max(1, min(20, $limit));

        $cacheKey = $this->cacheKey($path);
        $cached = get_transient($cacheKey);
        if (is_array($cached)) {
            // Ensure the cache shape is safe.
            return $this->sanitizeSuggestions($cached, $limit, $path);
        }

        try {
            $suggestions = $this->computeSuggestions($path, $limit);
            set_transient($cacheKey, $suggestions, DAY_IN_SECONDS);
            return $suggestions;
        } catch (\Throwable $e) {
            $this->logger->error('Redirect suggestion failed: ' . $e->getMessage());
            return [];
        }
    }

    /**
     * @param array<mixed> $raw
     * @return array<int, array{to: string, label: string, reason: string, score: float}>
     */
    private function sanitizeSuggestions(array $raw, int $limit, string $requestedPath): array
    {
        $out = [];
        foreach ($raw as $row) {
            if (!is_array($row)) {
                continue;
            }
            $to = isset($row['to']) ? (string) $row['to'] : '';
            $label = isset($row['label']) ? (string) $row['label'] : '';
            $reason = isset($row['reason']) ? (string) $row['reason'] : '';
            $score = isset($row['score']) ? (float) $row['score'] : 0.0;

            $to = trim($to);
            if ($to === '') {
                continue;
            }
            if ($this->normalizeComparePath($to) === $this->normalizeComparePath($requestedPath)) {
                continue;
            }

            $out[] = [
                'to' => $to,
                'label' => $label,
                'reason' => $reason,
                'score' => max(0.0, min(1.0, $score)),
            ];

            if (count($out) >= $limit) {
                break;
            }
        }
        return $out;
    }

    /**
     * @return array<int, array{to: string, label: string, reason: string, score: float}>
     */
    private function computeSuggestions(string $path, int $limit): array
    {
        $slugCandidates = $this->extractSlugCandidates($path);

        $suggestions = [];

        // 1) Strong signals.
        foreach ($slugCandidates as $slug) {
            foreach ($this->findPostsByOldSlug($slug, 5) as $p) {
                $suggestions[] = $this->makePostSuggestion($p, 'old_slug', 0.99);
            }
        }

        foreach ($slugCandidates as $slug) {
            foreach ($this->findPostsByExactSlug($slug, 5) as $p) {
                $suggestions[] = $this->makePostSuggestion($p, 'exact_slug', 0.95);
            }
        }

        // 2) Similar matches (we'll score them).
        foreach ($slugCandidates as $slug) {
            foreach ($this->findPostsBySimilarSlug($slug, 25) as $p) {
                $score = $this->similarityScore($slug, (string) ($p['post_name'] ?? ''));
                if ($score < 0.55) {
                    continue;
                }
                $suggestions[] = $this->makePostSuggestion($p, 'similar_slug', $score);
            }
        }

        // 2b) Title match (useful when permalink/slug changed).
        // Keep it slightly lower confidence than slug matches.
        foreach ($slugCandidates as $slug) {
            foreach ($this->findPostsByTitleWords($slug, 60) as $p) {
                $scoreTitle = $this->similarityScore($slug, (string) ($p['post_title'] ?? ''));
                $scoreName  = $this->similarityScore($slug, (string) ($p['post_name'] ?? ''));
                $score = max($scoreTitle, $scoreName);
                if ($score < 0.60) {
                    continue;
                }
                // Normalize into a slightly lower confidence band.
                $score = 0.55 + (0.45 * max(0.0, min(1.0, $score)));
                $suggestions[] = $this->makePostSuggestion($p, 'title_match', $score);
            }
        }

        // 3) Term matches (categories/tags/custom tax).
        foreach ($slugCandidates as $slug) {
            foreach ($this->findTermsBySlug($slug, 5) as $t) {
                $suggestions[] = $this->makeTermSuggestion($t, 'term_slug', 0.82);
            }
        }

        // Deduplicate by destination.
        $deduped = [];
        foreach ($suggestions as $s) {
            $key = $this->normalizeComparePath($s['to']);
            if ($key === '' || $key === $this->normalizeComparePath($path)) {
                continue;
            }
            if (!isset($deduped[$key]) || $s['score'] > $deduped[$key]['score']) {
                $deduped[$key] = $s;
            }
        }

        $suggestions = array_values($deduped);

        usort($suggestions, function ($a, $b) {
            $sa = isset($a['score']) ? (float) $a['score'] : 0.0;
            $sb = isset($b['score']) ? (float) $b['score'] : 0.0;
            if ($sa === $sb) {
                return 0;
            }
            return ($sa > $sb) ? -1 : 1;
        });

        if (count($suggestions) > $limit) {
            $suggestions = array_slice($suggestions, 0, $limit);
        }

        return $suggestions;
    }

    private function cacheKey(string $path): string
    {
        return 'wpsn_404_suggest_' . substr(md5($path), 0, 16);
    }

    /**
     * @return array<int, string>
     */
    private function extractSlugCandidates(string $path): array
    {
        $trimmed = trim($path, '/');
        if ($trimmed === '') {
            return [];
        }

        $parts = explode('/', $trimmed);
        $last = (string) end($parts);
        $last = rawurldecode($last);

        $candidates = [];

        if ($last !== '' && !ctype_digit($last)) {
            $candidates[] = $last;
        }

        // Strip typical extensions.
        if ($last !== '' && preg_match('/\.(html?|php|aspx?)$/i', $last)) {
            $noExt = preg_replace('/\.(html?|php|aspx?)$/i', '', $last);
            $noExt = is_string($noExt) ? $noExt : '';
            if ($noExt !== '' && !ctype_digit($noExt)) {
                $candidates[] = $noExt;
            }
        }

        // If last segment looks like a page number and URL contains /page/{n}/,
        // try the segment before "page".
        $count = count($parts);
        if ($count >= 2) {
            $prev = (string) $parts[$count - 2];
            if ($prev === 'page' && ctype_digit($last) && $count >= 3) {
                $maybe = (string) $parts[$count - 3];
                if ($maybe !== '' && !ctype_digit($maybe)) {
                    $candidates[] = $maybe;
                }
            }
        }

        // Normalize: lowercase and trim.
        $out = [];
        foreach ($candidates as $c) {
            $c = sanitize_title($c);
            if ($c === '') {
                continue;
            }
            $out[$c] = true;
        }

        return array_keys($out);
    }

    /**
     * @return array<int, array{ID: int, post_title: string, post_name: string, post_type: string}>
     */
    private function findPostsByOldSlug(string $slug, int $limit): array
    {
        global $wpdb;

        $slug = sanitize_title($slug);
        if ($slug === '') {
            return [];
        }

        $limit = max(1, min(50, $limit));

        $types = $this->publicPostTypes();
        if (empty($types)) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($types), '%s'));
        $params = array_merge([$slug], $types, [$limit]);

        $sql = "SELECT p.ID, p.post_title, p.post_name, p.post_type\n                FROM {$wpdb->posts} p\n                INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID\n                WHERE pm.meta_key = '_wp_old_slug' AND pm.meta_value = %s\n                  AND p.post_status = 'publish'\n                  AND p.post_type IN ({$placeholders})\n                ORDER BY p.post_date DESC\n                LIMIT %d";

        $prepared = $wpdb->prepare($sql, $params);
        $rows = $wpdb->get_results($prepared, ARRAY_A);

        return is_array($rows) ? $rows : [];
    }

    /**
     * @return array<int, array{ID: int, post_title: string, post_name: string, post_type: string}>
     */
    private function findPostsByExactSlug(string $slug, int $limit): array
    {
        global $wpdb;

        $slug = sanitize_title($slug);
        if ($slug === '') {
            return [];
        }

        $limit = max(1, min(50, $limit));

        $types = $this->publicPostTypes();
        if (empty($types)) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($types), '%s'));
        $params = array_merge([$slug], $types, [$limit]);

        $sql = "SELECT ID, post_title, post_name, post_type\n                FROM {$wpdb->posts}\n                WHERE post_name = %s\n                  AND post_status = 'publish'\n                  AND post_type IN ({$placeholders})\n                ORDER BY post_date DESC\n                LIMIT %d";

        $prepared = $wpdb->prepare($sql, $params);
        $rows = $wpdb->get_results($prepared, ARRAY_A);

        return is_array($rows) ? $rows : [];
    }

    /**
     * @return array<int, array{ID: int, post_title: string, post_name: string, post_type: string}>
     */
    private function findPostsBySimilarSlug(string $slug, int $limit): array
    {
        global $wpdb;

        $slug = sanitize_title($slug);
        if ($slug === '') {
            return [];
        }

        $limit = max(1, min(200, $limit));

        $types = $this->publicPostTypes();
        if (empty($types)) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($types), '%s'));

        $like = '%' . $wpdb->esc_like($slug) . '%';

        $params = array_merge([$like], $types, [$limit]);

        $sql = "SELECT ID, post_title, post_name, post_type\n                FROM {$wpdb->posts}\n                WHERE post_name LIKE %s\n                  AND post_status = 'publish'\n                  AND post_type IN ({$placeholders})\n                ORDER BY post_date DESC\n                LIMIT %d";

        $prepared = $wpdb->prepare($sql, $params);
        $rows = $wpdb->get_results($prepared, ARRAY_A);

        return is_array($rows) ? $rows : [];
    }

    /**
     * @return array<int, array{term_id: int, name: string, taxonomy: string, slug: string}>
     */
    private function findTermsBySlug(string $slug, int $limit): array
    {
        global $wpdb;

        $slug = sanitize_title($slug);
        if ($slug === '') {
            return [];
        }

        $limit = max(1, min(50, $limit));

        $tax = get_taxonomies(['public' => true], 'names');
        if (!is_array($tax) || empty($tax)) {
            return [];
        }

        // Be conservative: exclude internal WP taxonomies.
        $tax = array_values(array_filter($tax, function ($t) {
            return is_string($t) && $t !== '' && $t !== 'nav_menu' && $t !== 'link_category' && $t !== 'post_format';
        }));

        if (empty($tax)) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($tax), '%s'));
        $params = array_merge([$slug], $tax, [$limit]);

        $sql = "SELECT t.term_id, t.name, tt.taxonomy, t.slug\n                FROM {$wpdb->terms} t\n                INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id\n                WHERE t.slug = %s\n                  AND tt.taxonomy IN ({$placeholders})\n                ORDER BY tt.count DESC, t.term_id DESC\n                LIMIT %d";

        $prepared = $wpdb->prepare($sql, $params);
        $rows = $wpdb->get_results($prepared, ARRAY_A);

        return is_array($rows) ? $rows : [];
    }

    /**
     * @return array<int, string>
     */
    private function publicPostTypes(): array
    {
        $types = get_post_types(['public' => true, 'publicly_queryable' => true], 'names');
        if (!is_array($types)) {
            $types = [];
        }

        $types = array_values(array_filter($types, function ($t) {
            if (!is_string($t) || $t === '') {
                return false;
            }
            // Exclude internals.
            return !in_array($t, ['revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request'], true);
        }));

        return $types;
    }

    /**
     * @param array<string,mixed> $p
     * @return array{to: string, label: string, reason: string, score: float}
     */
    private function makePostSuggestion(array $p, string $reasonKey, float $score): array
    {
        $id = isset($p['ID']) ? (int) $p['ID'] : 0;
        $title = isset($p['post_title']) ? (string) $p['post_title'] : '';
        $type = isset($p['post_type']) ? (string) $p['post_type'] : '';

        $url = $id > 0 ? get_permalink($id) : '';
        $to = $this->toRelativeIfLocal(is_string($url) ? $url : '');

        $label = $title !== '' ? $title : (string) __('(no title)', 'wp-seo-next');
        if ($type !== '') {
            $label = '[' . $type . '] ' . $label;
        }

        return [
            'to' => $to,
            'label' => $label,
            'reason' => $this->reasonLabel($reasonKey),
            'score' => max(0.0, min(1.0, $score)),
        ];
    }

    /**
     * @param array<string,mixed> $t
     * @return array{to: string, label: string, reason: string, score: float}
     */
    private function makeTermSuggestion(array $t, string $reasonKey, float $score): array
    {
        $termId = isset($t['term_id']) ? (int) $t['term_id'] : 0;
        $name = isset($t['name']) ? (string) $t['name'] : '';
        $taxonomy = isset($t['taxonomy']) ? (string) $t['taxonomy'] : '';

        $url = ($termId > 0 && $taxonomy !== '') ? get_term_link($termId, $taxonomy) : '';
        $to = $this->toRelativeIfLocal(is_string($url) ? $url : '');

        $label = $name !== '' ? $name : (string) __('(term)', 'wp-seo-next');
        if ($taxonomy !== '') {
            $label = '[' . $taxonomy . '] ' . $label;
        }

        return [
            'to' => $to,
            'label' => $label,
            'reason' => $this->reasonLabel($reasonKey),
            'score' => max(0.0, min(1.0, $score)),
        ];
    }

    private function reasonLabel(string $reasonKey): string
    {
        switch ($reasonKey) {
            case 'old_slug':
                return (string) __('Matched old slug', 'wp-seo-next');
            case 'exact_slug':
                return (string) __('Exact slug match', 'wp-seo-next');
            case 'similar_slug':
                return (string) __('Similar slug', 'wp-seo-next');
            case 'term_slug':
                return (string) __('Term slug match', 'wp-seo-next');
            case 'title_match':
                return (string) __('Title match', 'wp-seo-next');
            default:
                return (string) __('Suggestion', 'wp-seo-next');
        }
    }

    /**
     * Find posts by words extracted from a missing slug.
     *
     * @return array<int, array<string, mixed>>
     */
    private function findPostsByTitleWords(string $slug, int $limit = 50): array
    {
        global $wpdb;
        if (!isset($wpdb) || !is_object($wpdb)) {
            return [];
        }

        $slug = trim(rawurldecode($slug));
        if ($slug === '') {
            return [];
        }

        $phrase = str_replace(['-', '_'], ' ', $slug);
        $phrase = preg_replace('/[^\pL\pN\s]+/u', ' ', (string) $phrase);
        $phrase = trim((string) $phrase);
        if ($phrase === '') {
            return [];
        }

        $words = preg_split('/\s+/', $phrase);
        if (!is_array($words)) {
            $words = [];
        }

        $words = array_values(array_filter(array_map('trim', $words), static function ($w) {
            if (!is_string($w)) {
                return false;
            }
            // Skip very short tokens.
            return strlen($w) >= 4;
        }));

        if (empty($words)) {
            return [];
        }

        // Keep SQL light.
        $words = array_slice($words, 0, 3);

        $types = get_post_types(['public' => true], 'names');
        if (!is_array($types)) {
            $types = [];
        }

        // Remove post types that do not represent public URLs.
        unset($types['attachment'], $types['revision'], $types['nav_menu_item'], $types['custom_css'], $types['customize_changeset']);
        $types = array_values(array_filter(array_map('sanitize_key', $types)));
        if (empty($types)) {
            return [];
        }

        $typeSql = "'" . implode("','", array_map('esc_sql', $types)) . "'";

        $likeParts = [];
        $params = [];
        foreach ($words as $w) {
            $likeParts[] = 'post_title LIKE %s';
            $params[] = '%' . $wpdb->esc_like($w) . '%';
        }
        if (empty($likeParts)) {
            return [];
        }
        $whereLikes = '(' . implode(' OR ', $likeParts) . ')';

        $limit = max(1, min(200, $limit));

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $sql = $wpdb->prepare(
            "SELECT ID, post_title, post_name, post_type FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$typeSql}) AND {$whereLikes} ORDER BY post_date_gmt DESC, ID DESC LIMIT %d",
            array_merge($params, [$limit])
        );

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $rows = $wpdb->get_results($sql, ARRAY_A);
        return is_array($rows) ? $rows : [];
    }

    private function toRelativeIfLocal(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        $home = home_url('/');
        $homeHost = wp_parse_url($home, PHP_URL_HOST);
        $host = wp_parse_url($url, PHP_URL_HOST);

        if (is_string($homeHost) && $homeHost !== '' && is_string($host) && $host !== '' && strtolower($homeHost) === strtolower($host)) {
            $path = wp_parse_url($url, PHP_URL_PATH);
            if (is_string($path) && $path !== '') {
                return RedirectsUrl::normalizeFrom($path);
            }
        }

        // If already relative.
        if (!preg_match('#^https?://#i', $url)) {
            return RedirectsUrl::normalizeFrom($url);
        }

        return esc_url_raw($url);
    }

    private function normalizeComparePath(string $urlOrPath): string
    {
        $p = trim((string) wp_parse_url($urlOrPath, PHP_URL_PATH));
        if ($p === '') {
            // Might be a path already.
            $p = trim($urlOrPath);
        }
        $p = RedirectsUrl::normalizeFrom($p);
        return rtrim($p, '/');
    }

    private function similarityScore(string $a, string $b): float
    {
        $a = sanitize_title($a);
        $b = sanitize_title($b);
        if ($a === '' || $b === '') {
            return 0.0;
        }

        // Token overlap.
        $ta = array_values(array_filter(explode('-', $a)));
        $tb = array_values(array_filter(explode('-', $b)));
        $ia = array_intersect($ta, $tb);
        $ua = array_unique(array_merge($ta, $tb));
        $j = !empty($ua) ? (count($ia) / count($ua)) : 0.0;

        // Levenshtein distance.
        $maxLen = max(strlen($a), strlen($b));
        $lev = $maxLen > 0 ? levenshtein($a, $b) : $maxLen;
        $levScore = $maxLen > 0 ? (1.0 - min(1.0, ($lev / $maxLen))) : 0.0;

        // Weighted blend.
        $score = (0.65 * $j) + (0.35 * $levScore);
        return max(0.0, min(1.0, $score));
    }
}
