<?php

declare(strict_types=1);

namespace ProRank\SEO\Core\Content;

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

/**
 * Phase 3 — deterministic, per-title SEO-title rewrite workflow.
 *
 * Hard rules (enforced here, not assumed):
 *  - NEVER touches post_title or post_content. The only thing written is the
 *    `_prorank_seo_title` post meta.
 *  - Never overwrites an existing _prorank_seo_title without first saving a
 *    restorable backup.
 *  - suggest() is pure/read-only. apply()/restore() default to dry_run.
 *  - Suggestions are deterministic (no AI in P0): trim trailing brand/site
 *    segments, drop bracketed asides, then word-boundary truncate. The result
 *    is only ever a suggestion the operator explicitly commits per title.
 */
class TitleRewriter {

    /** Meta holding the most-recent restorable pre-apply snapshot. */
    public const BACKUP_META_KEY = '_prorank_title_backup';

    /** Separators used to peel trailing brand/site segments. */
    private const SEGMENT_SEPARATORS = ['|', '–', '—', '-', ':', '·', '»', '•'];

    private function len(string $s): int {
        return function_exists('mb_strlen') ? mb_strlen($s) : strlen($s);
    }

    private function clean(string $s): string {
        return trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags($s)));
    }

    private function sub(string $s, int $len): string {
        return function_exists('mb_substr') ? mb_substr($s, 0, $len) : substr($s, 0, $len);
    }

    /**
     * Deterministic suggestion for a single post. Pure — writes nothing.
     *
     * @return array{
     *   post_id:int, limit:int, source:string,
     *   current_title:string, current_len:int,
     *   base_title:string,
     *   suggested_title:string, suggested_len:int,
     *   method:string, within_limit:bool, changed:bool,
     *   status:string
     * }
     */
    public function suggest(int $post_id): array {
        $auditor = new TitleAuditor();
        $d = $auditor->diagnose_post($post_id);
        $limit = (int) $d['limit'];

        $current = $this->clean((string) $d['effective_title']);
        $base = $this->clean((string) $d['base_title']);

        $result = [
            'post_id' => $post_id,
            'limit' => $limit,
            'source' => (string) $d['source'],
            'current_title' => $current,
            'current_len' => (int) $d['effective_len'],
            'base_title' => $base,
            'suggested_title' => $current,
            'suggested_len' => $this->len($current),
            'method' => 'none',
            'within_limit' => $this->len($current) <= $limit,
            'changed' => false,
            'status' => (string) $d['status'],
        ];

        if ($d['status'] === 'post_not_found') {
            $result['method'] = 'post_not_found';
            return $result;
        }
        if ($this->len($current) <= $limit) {
            // Already fine (e.g. render-time sitename omission handles it).
            $result['method'] = 'already_within_limit';
            return $result;
        }

        $candidate = $this->deterministic_shorten($current, $base, $limit, $method);

        $result['suggested_title'] = $candidate;
        $result['suggested_len'] = $this->len($candidate);
        $result['method'] = $method;
        $result['within_limit'] = $this->len($candidate) <= $limit && $candidate !== '';
        $result['changed'] = $candidate !== '' && $candidate !== $current;
        return $result;
    }

    /**
     * Ordered deterministic strategies. Returns the first candidate that is
     * non-empty and within the limit; otherwise the best hard-truncated form.
     */
    private function deterministic_shorten(string $current, string $base, int $limit, &$method = null): string {
        // 1. Bare base post title (drops any plugin/brand suffix entirely).
        $base = $this->strip_brackets($base);
        if ($base !== '' && $this->len($base) <= $limit) {
            $method = 'base_title';
            return $base;
        }

        // 2. Peel trailing separator-delimited segments off the current title,
        //    keeping at least the first (primary) segment.
        $peeled = $this->peel_trailing_segments($current, $limit);
        if ($peeled !== '' && $this->len($peeled) <= $limit) {
            $method = 'trimmed_trailing_segments';
            return $peeled;
        }

        // 3. Same peel on the bracket-stripped base.
        $peeled_base = $this->peel_trailing_segments($base, $limit);
        if ($peeled_base !== '' && $this->len($peeled_base) <= $limit) {
            $method = 'trimmed_base_segments';
            return $peeled_base;
        }

        // 4. Word-boundary truncate the primary segment of the base/current.
        $primary = $this->first_segment($base !== '' ? $base : $current);
        $wb = $this->word_truncate($primary, $limit);
        if ($wb !== '' && $this->len($wb) <= $limit) {
            $method = 'word_boundary_truncate';
            return $wb;
        }

        // 5. Last resort: hard character truncate (no ellipsis stuffing).
        $method = 'hard_truncate';
        return rtrim($this->sub($primary !== '' ? $primary : $current, $limit), " \t\n\r\0\x0B-–—|:·»•");
    }

    private function strip_brackets(string $s): string {
        $s = (string) preg_replace('/\s*[\(\[\{][^\)\]\}]*[\)\]\}]\s*/u', ' ', $s);
        return $this->clean($s);
    }

    private function first_segment(string $s): string {
        $pattern = '/\s*[' . preg_quote(implode('', self::SEGMENT_SEPARATORS), '/') . ']\s*/u';
        $parts = preg_split($pattern, $s) ?: [$s];
        return $this->clean((string) ($parts[0] ?? $s));
    }

    private function peel_trailing_segments(string $s, int $limit): string {
        $pattern = '/\s*[' . preg_quote(implode('', self::SEGMENT_SEPARATORS), '/') . ']\s*/u';
        $parts = preg_split($pattern, $s) ?: [$s];
        $parts = array_values(array_filter(array_map([$this, 'clean'], $parts), static fn($p) => $p !== ''));
        if (count($parts) <= 1) {
            return '';
        }
        while (count($parts) > 1) {
            array_pop($parts);
            $joined = implode(' - ', $parts);
            if ($this->len($joined) <= $limit) {
                return $joined;
            }
        }
        return $this->clean($parts[0]);
    }

    private function word_truncate(string $s, int $limit): string {
        $s = $this->clean($s);
        if ($this->len($s) <= $limit) {
            return $s;
        }
        $slice = $this->sub($s, $limit);
        $pos = function_exists('mb_strrpos') ? mb_strrpos($slice, ' ') : strrpos($slice, ' ');
        if ($pos !== false && $pos > 0) {
            $slice = $this->sub($slice, (int) $pos);
        }
        return rtrim($slice, " \t\n\r\0\x0B-–—|:·»•,;");
    }

    private function backup(int $post_id): void {
        $existing = get_post_meta($post_id, '_prorank_seo_title', true);
        update_post_meta($post_id, self::BACKUP_META_KEY, [
            'backed_up_at' => current_time('mysql'),
            'prev_seo_title' => is_string($existing) ? $existing : '',
            'had_value' => $existing !== '' && $existing !== false && $existing !== null,
        ]);
    }

    /**
     * Targeted, per-post cache refresh. Modeled on
     * H1Repairer::refresh_runtime_caches(): writing _prorank_seo_title only
     * busts the post object cache by default, so ProRank's HTML/page cache
     * would keep serving the OLD <title> until expiry. Purge just this post —
     * NEVER a full-site clear_all_cache().
     */
    private function refresh_runtime_caches(int $post_id): void {
        clean_post_cache($post_id);
        wp_cache_delete($post_id, 'posts');
        wp_cache_delete($post_id, 'post_meta');

        if (class_exists('\\ProRank\\SEO\\Plugin')) {
            try {
                $plugin = \ProRank\SEO\Plugin::get_instance();
                if ($plugin && method_exists($plugin, 'modules')) {
                    $url = get_permalink($post_id) ?: '';
                    foreach (['modern_cache', 'cache'] as $slug) {
                        $module = $plugin->modules()->get_module($slug);
                        if (!$module) {
                            continue;
                        }
                        if (method_exists($module, 'smart_cache_invalidation')) {
                            $module->smart_cache_invalidation($post_id);
                        } elseif ($url !== '' && method_exists($module, 'purge_url')) {
                            $module->purge_url($url);
                        } elseif ($url !== '' && method_exists($module, 'invalidate_url')) {
                            $module->invalidate_url($url);
                        }
                        // Intentionally NO clear_all_cache() fallback.
                    }
                }
            } catch (\Throwable $e) {
                // Title write is the primary operation; cache refresh is best-effort.
            }
        }
    }

    /**
     * Apply an explicit per-title SEO title. Writes ONLY _prorank_seo_title.
     * Defaults to a dry run; a real write needs $dry_run = false.
     */
    public function apply(int $post_id, string $title, bool $dry_run = true): array {
        if (!get_post($post_id)) {
            return ['post_id' => $post_id, 'mutated' => false, 'reason' => 'post_not_found'];
        }
        $title = $this->clean($title);
        if ($title === '') {
            return ['post_id' => $post_id, 'mutated' => false, 'reason' => 'empty_title'];
        }

        $prev = (string) get_post_meta($post_id, '_prorank_seo_title', true);
        if ($prev === $title) {
            return ['post_id' => $post_id, 'mutated' => false, 'reason' => 'unchanged', 'seo_title' => $title];
        }

        if (!$dry_run) {
            $this->backup($post_id);
            update_post_meta($post_id, '_prorank_seo_title', $title);
            $this->refresh_runtime_caches($post_id);
        }

        return [
            'post_id' => $post_id,
            'mutated' => !$dry_run,
            'dry_run' => $dry_run,
            'reason' => $dry_run ? 'dry_run' : 'applied',
            'previous_seo_title' => $prev,
            'seo_title' => $title,
        ];
    }

    /** Revert the most-recent applied SEO title from backup. */
    public function restore(int $post_id, bool $dry_run = true): array {
        $snap = get_post_meta($post_id, self::BACKUP_META_KEY, true);
        if (!is_array($snap) || !isset($snap['backed_up_at'])) {
            return ['post_id' => $post_id, 'restored' => false, 'reason' => 'no_backup'];
        }
        if (!$dry_run) {
            if (!empty($snap['had_value'])) {
                update_post_meta($post_id, '_prorank_seo_title', (string) $snap['prev_seo_title']);
            } else {
                delete_post_meta($post_id, '_prorank_seo_title');
            }
            $this->refresh_runtime_caches($post_id);
            delete_post_meta($post_id, self::BACKUP_META_KEY);
        }
        return [
            'post_id' => $post_id,
            'restored' => !$dry_run,
            'dry_run' => $dry_run,
            'backed_up_at' => (string) $snap['backed_up_at'],
            'reason' => $dry_run ? 'dry_run' : 'restored',
        ];
    }

    /**
     * P0 repair-all is DRY-RUN ONLY: it returns proposed titles for every
     * needs_rewrite candidate and never writes, regardless of arguments.
     */
    public function repair_all_preview(int $limit = 200): array {
        $limit = max(1, min(500, $limit));
        $scan = (new TitleAuditor())->scan();
        $items = [];
        $n = 0;
        foreach ($scan['items'] as $cand) {
            if ($n >= $limit) {
                break;
            }
            $n++;
            $s = $this->suggest((int) $cand['id']);
            $items[] = [
                'id' => (int) $cand['id'],
                'title' => (string) $cand['title'],
                'source' => (string) $cand['source'],
                'current_title' => $s['current_title'],
                'current_len' => $s['current_len'],
                'suggested_title' => $s['suggested_title'],
                'suggested_len' => $s['suggested_len'],
                'method' => $s['method'],
                'within_limit' => $s['within_limit'],
                'edit_link' => admin_url('post.php?post=' . (int) $cand['id'] . '&action=edit'),
            ];
        }
        return [
            'dry_run' => true,
            'note' => 'P0 repair-all is preview only; titles are applied one at a time with explicit approval.',
            'limit' => $limit,
            'examined' => $n,
            'items' => $items,
        ];
    }
}
