<?php
/**
 * Tam Rank™ Redirect Manager
 */

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

class TamRank_Redirect_Manager {
    
    public function __construct() {
        add_action('template_redirect', array($this, 'handle_redirects'), 1);
        add_action('wp_ajax_tam_rank_create_redirect', array($this, 'ajax_create_redirect'));
        add_action('wp_ajax_tam_rank_update_redirect', array($this, 'ajax_update_redirect'));
        add_action('wp_ajax_tam_rank_toggle_redirect', array($this, 'ajax_toggle_redirect'));
        add_action('wp_ajax_tam_rank_delete_redirect', array($this, 'ajax_delete_redirect'));
        add_action('wp_ajax_tam_rank_clear_404_log', array($this, 'ajax_clear_404_log'));
        add_action('wp_ajax_tam_rank_ignore_404_urls', array($this, 'ajax_ignore_404_urls'));
        // Direct CSV download via admin page URL param (bypasses AJAX)
        add_action('admin_init', array($this, 'handle_404_csv_download'));
        add_action('wp_ajax_tam_rank_export_redirects', array($this, 'ajax_export_redirects'));
        add_action('wp_ajax_tam_rank_import_redirects', array($this, 'ajax_import_redirects'));
    }
    
    /**
     * Handle redirects on frontend
     */
    public function handle_redirects() {
        if (is_admin()) {
            return;
        }

        $current_url = $this->get_request_uri();
        if ($current_url === '') {
            return;
        }

        // Split the request once. Everything before the first '?' is the path a
        // rule is matched against, everything after it is the visitor's own
        // query string.
        $mark          = strpos($current_url, '?');
        $request_path  = ($mark === false) ? $current_url : substr($current_url, 0, $mark);
        $request_query = ($mark === false) ? '' : substr($current_url, $mark + 1);

        // A source may deliberately have been stored *with* a query string —
        // clean_source_url() keeps one — so the full request URI gets the first
        // and most specific shot. Only when that misses do we fall back to the
        // bare path: that is what makes /old-page/?utm_source=x hit the rule
        // stored as /old-page/ instead of dropping the visitor on a 404.
        $redirect    = $this->get_redirect_for_url($current_url);
        $carry_query = '';

        if (!$redirect && $mark !== false) {
            $path_match = $this->get_redirect_for_url($request_path);
            // Matching on the path alone can turn a rule that used to be inert
            // into an endless loop: a rule /a/ -> /a/?ref=1 never matched
            // /a/?ref=1 before, but the bare path does. Refuse the fallback when
            // the target resolves to the very path we were asked for.
            if ($path_match && !$this->target_is_same_path($path_match->target_url, $request_path)) {
                $redirect    = $path_match;
                $carry_query = $request_query;
            }
        }

        // No exact hit? Then, and only then, walk the regex rules. Exact rules
        // stay a single indexed lookup; the regex set comes from a cache, so a
        // site without regex rules pays nothing at all for this.
        //
        // A pattern keeps running against the FULL request URI, query string
        // included. That is deliberate: ^/old/(.*)$ swallows the query into its
        // capture group on purpose, and a pattern may have been written to match
        // on the query itself. Matching patterns on the bare path instead would
        // silently rewrite what every existing rule means. $carry_query stays
        // empty on a regex hit, so nothing is appended twice.
        $regex_target = null;
        if (!$redirect) {
            $regex_hit = $this->match_regex_redirect($current_url);
            if ($regex_hit) {
                $redirect     = $regex_hit['redirect'];
                $regex_target = $regex_hit['target_url'];
            }
        }

        // Last resort: the trailing-slash twin of the requested path. A rule
        // stored as /old-page only fires on /old-page, so /old-page/ 404s even
        // though both spellings mean the same page to everyone but us.
        //
        // is_404() is what makes this safe, and it is not optional. We run on
        // template_redirect priority 1 without ever asking whether the URL has
        // real content behind it, so normalising the slash unconditionally would
        // let a rule for /a hijack the LIVE page /a/. By this point WordPress has
        // parsed the request and its rewrite rules resolve /a and /a/ to the same
        // post, so is_404() is false there and the hijack cannot happen. It is
        // also the last step on purpose: everything the administrator typed
        // literally — an exact source, an exact source with its own query, an
        // explicit pattern — gets first refusal before we guess at a spelling.
        if (!$redirect && is_404()) {
            $slash_variant = $this->toggle_trailing_slash($request_path);
            if ($slash_variant !== '') {
                $slash_match = $this->get_redirect_for_url($slash_variant);
                // Same loop guard as the bare-path step: a rule /a -> /a/ must
                // not answer the request for /a/ with a redirect to /a/.
                if ($slash_match && !$this->target_is_same_path($slash_match->target_url, $request_path)) {
                    $redirect    = $slash_match;
                    $carry_query = $request_query;
                }
            }
        }

        // A rule the PLUGIN wrote was written on the premise that its source URL
        // was dead: the page had just been trashed, or its slug had just moved.
        // That premise expires the moment content lives at that URL again -
        // restored from the trash, rebuilt under the old slug, or imported from
        // another SEO plugin onto a URL this site still serves. Left unchecked
        // the rule wins over the real page, so an editor publishes a page, sees
        // it live in wp-admin, and every visitor is still bounced somewhere
        // else. Switching the plugin off "fixes" it, which is exactly how the
        // report reaches us.
        //
        // is_404() alone is what the trailing-slash step already leans on and
        // it is available here for the same reason: by template_redirect the
        // request is parsed. Hand-typed rules are left firing - redirecting a
        // page you deliberately keep published is a legitimate thing to
        // configure, and silently dropping those would break working sites.
        if ($redirect && $redirect->active && $this->should_yield_to_live_content($redirect)) {
            $redirect = null;
        }

        if ($redirect && $redirect->active) {
            // Log hit
            $this->log_redirect_hit($redirect->id);

            // A regex rule may resolve $1..$9 in its target. Keep the target
            // as it was configured too: the host whitelist further down has to
            // come from that one, never from the substituted one.
            $configured_target = $redirect->target_url;
            if ($regex_target !== null) {
                $redirect->target_url = $regex_target;
            }

            // Non-redirection statuses (410 Gone, 451 Unavailable For Legal
            // Reasons, or anything an add-on allows via tamrank_redirect_types)
            // can never go through wp_safe_redirect():
            // wp_redirect() wp_die()s on any status outside 3xx. Serve them here.
            $status = intval($redirect->redirect_type);
            if ($status < 300 || $status > 399) {
                $this->serve_non_redirect_status($status);
            }

            // Carry the visitor's query string over to the target. Empty unless
            // the rule was matched on the bare path, so an exact rule that
            // already covers the query string is left exactly as configured.
            $target_url = $this->carry_query_string($redirect->target_url, $carry_query);

            // Perform redirect. External targets are a legitimate feature, so
            // whitelist this redirect's own host through allowed_redirect_hosts
            // and use wp_safe_redirect: configured externals keep working while
            // dangerous schemes (javascript:/data:) or malformed targets fall
            // back to a safe URL instead of bouncing the visitor.
            //
            // The host comes from the target as the ADMINISTRATOR configured it,
            // never from the substituted one. On a regex rule the capture groups
            // are filled from the visitor's own request, so reading the host
            // after substitution would whitelist a host the VISITOR picked:
            // ^/out/(.*)$ -> https://$1 turns every request into an open
            // redirect to wherever the visitor likes, and the typo
            // https://partner.example$1 does the same one level deeper. A
            // configured host that still holds a backreference is no host at
            // all, so nothing is whitelisted and wp_safe_redirect() falls back
            // to a safe URL. A fixed external host — the deliberate feature —
            // is unaffected: it is already final before any substitution.
            $target_host = wp_parse_url($configured_target, PHP_URL_HOST);
            if ($target_host && strpos($target_host, '$') === false) {
                add_filter('allowed_redirect_hosts', function ($hosts) use ($target_host) {
                    $hosts[] = $target_host;
                    return $hosts;
                });
            }
            wp_safe_redirect($target_url, intval($redirect->redirect_type));
            exit;
        }
    }

    /**
     * Should this rule step aside because the requested URL now has real,
     * publicly visible content behind it?
     *
     * Only plugin-generated rules yield. Those are created by the delete prompt
     * and by the slug field in the meta box, always for a URL that was dead at
     * that moment; a live page at that same URL means the rule outlived its
     * reason. A rule an administrator typed is left alone - see the caller.
     *
     * Sites that want the same protection for hand-typed rules can opt in:
     * add_filter('tamrank_redirect_yield_to_live_content', '__return_true');
     * The filter receives the rule, so a site can also decide per rule.
     *
     * @param object $redirect Redirect row as stored.
     * @return bool True when the rule must not fire for this request.
     */
    private function should_yield_to_live_content($redirect) {
        $yield = !empty($redirect->auto_generated);

        /**
         * Filter whether a rule yields to live content at its source URL.
         *
         * @param bool   $yield    Default: true for plugin-generated rules only.
         * @param object $redirect The redirect row.
         */
        $yield = (bool) apply_filters('tamrank_redirect_yield_to_live_content', $yield, $redirect);

        if (!$yield) {
            return false;
        }

        return $this->request_has_live_content();
    }

    /**
     * Does the current request resolve to a published, publicly viewable post?
     *
     * Deliberately narrow. A 404 is the normal case a redirect exists for, and
     * an archive or the blog home is not a page anyone "published at this URL",
     * so neither of those counts. Only a singular post whose status is public
     * - so a draft, a pending page or a private page still lets the rule fire -
     * is treated as content the rule must not hide.
     *
     * @return bool
     */
    private function request_has_live_content() {
        if (is_404() || !is_singular()) {
            return false;
        }

        $object = get_queried_object();
        if (!$object instanceof WP_Post) {
            return false;
        }

        $status = get_post_status_object(get_post_status($object));

        return $status && !empty($status->public);
    }

    /**
     * The incoming request URI, safe to compare against a stored source_url.
     *
     * sanitize_text_field() is the wrong tool here: it strips every %XX
     * sequence, so an encoded path (/caf%C3%A9/) or an encoded query value
     * (?utm_campaign=zomer%20actie) is silently mangled before it is ever
     * compared or passed on. esc_url_raw() keeps the percent-encoding intact
     * while dropping control characters, encoded CRLF and anything else that
     * does not belong in a URI.
     *
     * @return string Request URI including its query string, or '' when absent.
     */
    private function get_request_uri() {
        if (!isset($_SERVER['REQUEST_URI'])) {
            return '';
        }

        return (string) esc_url_raw(wp_unslash($_SERVER['REQUEST_URI']));
    }

    /**
     * Append the visitor's query string to a redirect target.
     *
     * Redirects exist so links shared elsewhere keep working, and those links
     * nearly always carry something behind the question mark (?utm_source=,
     * ?fbclid=, ?gclid=, ?ref=). Dropping it would strip the campaign data off
     * exactly the traffic a redirect is meant to rescue, so the query string
     * travels along — the same default Rank Math ships.
     *
     * Parameters the target already sets win: those were typed by hand in the
     * Redirect Manager, so an incoming ?lang=en must not silently override a
     * configured ?lang=nl. Pairs are moved across as raw strings, never through
     * parse_str()/http_build_query(), so encoding and repeated keys survive.
     *
     * @param string $target_url Target as configured on the rule.
     * @param string $query      Raw query string from the request, without '?'.
     * @return string Target URL with the query string merged in.
     */
    private function carry_query_string($target_url, $query) {
        $query = trim((string) $query, '&');
        if ($query === '') {
            return $target_url;
        }

        // A fragment never reaches the server, but a target may carry one of its
        // own. Park it so the query lands in front of it.
        $fragment = '';
        $hash     = strpos($target_url, '#');
        if ($hash !== false) {
            $fragment   = substr($target_url, $hash);
            $target_url = substr($target_url, 0, $hash);
        }

        $mark = strpos($target_url, '?');
        if ($mark === false) {
            return $target_url . '?' . $query . $fragment;
        }

        $target_query = substr($target_url, $mark + 1);
        if ($target_query === '') {
            // Target ends in a bare '?'.
            return $target_url . $query . $fragment;
        }

        $taken = array();
        foreach (explode('&', $target_query) as $pair) {
            if ($pair === '') {
                continue;
            }
            $taken[] = $this->query_pair_key($pair);
        }

        $carried = array();
        foreach (explode('&', $query) as $pair) {
            if ($pair === '' || in_array($this->query_pair_key($pair), $taken, true)) {
                continue;
            }
            $carried[] = $pair;
        }

        if (empty($carried)) {
            return $target_url . $fragment;
        }

        return $target_url . '&' . implode('&', $carried) . $fragment;
    }

    /**
     * Key half of a raw query pair ('utm_source=x' -> 'utm_source').
     *
     * @param string $pair
     * @return string
     */
    private function query_pair_key($pair) {
        $eq = strpos($pair, '=');
        return ($eq === false) ? $pair : substr($pair, 0, $eq);
    }

    /**
     * Would redirecting to this target land us back on the path we came from?
     *
     * Only consulted for the bare-path fallback, where a rule such as
     * /a/ -> /a/?ref=1 would otherwise bounce forever.
     *
     * @param string $target_url   Target as configured on the rule.
     * @param string $request_path Requested path, without query string.
     * @return bool
     */
    private function target_is_same_path($target_url, $request_path) {
        $target_host = wp_parse_url($target_url, PHP_URL_HOST);
        if ($target_host) {
            $home_host = wp_parse_url(home_url(), PHP_URL_HOST);
            if (strtolower($target_host) !== strtolower((string) $home_host)) {
                return false;
            }
        }

        $target_path = wp_parse_url($target_url, PHP_URL_PATH);

        return is_string($target_path) && $target_path !== '' && $target_path === $request_path;
    }

    /**
     * The trailing-slash twin of a request path.
     *
     * /old-page becomes /old-page/ and the other way around. Only consulted for
     * the is_404() fallback, where the requested spelling matched nothing.
     *
     * The site root has no twin worth trying: '/' would become '' and '//'
     * would become '/', which would hand a homepage rule a request it never
     * claimed.
     *
     * @param string $request_path Requested path, without query string.
     * @return string Twin path, or '' when there is none to try.
     */
    private function toggle_trailing_slash($request_path) {
        if ($request_path === '' || $request_path === '/') {
            return '';
        }

        if (substr($request_path, -1) === '/') {
            $trimmed = substr($request_path, 0, -1);
            return ($trimmed === '' || $trimmed === '/') ? '' : $trimmed;
        }

        return $request_path . '/';
    }

    /**
     * Serve a non-redirection status (410 Gone, 451 Unavailable For Legal
     * Reasons) for a matched rule.
     *
     * Sends the status code, marks the request as not-found and renders the
     * theme's 404 template so the visitor sees a real page while search
     * engines pick up the status code. The rule's target URL is ignored:
     * there is nothing to redirect to.
     *
     * @param int $status HTTP status code (non-3xx).
     */
    private function serve_non_redirect_status($status) {
        global $wp_query;

        if ($wp_query instanceof WP_Query) {
            $wp_query->set_404();
        }

        status_header($status);
        nocache_headers();

        $template = get_404_template();
        if ($template) {
            include $template;
        } else {
            // Theme without a 404 template: minimal body, the status code
            // is what matters here. 451 gets its own line because "permanently
            // removed" is the wrong thing to tell a visitor about a page that
            // is being withheld on legal grounds; every other non-3xx status
            // keeps the wording it had.
            if ($status === 451) {
                echo esc_html(tamrank__('legal_page_blocked', 'This page is unavailable for legal reasons.'));
            } else {
                echo esc_html(tamrank__('gone_page_removed', 'This page has been permanently removed.'));
            }
        }
        exit;
    }

    /**
     * Get redirect for specific URL
     */
    private function get_redirect_for_url($url) {
        global $wpdb;
        
        $table_name = $wpdb->prefix . 'tam_rank_redirects';
        
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); request-scoped read, caching not applicable; only the table identifier is interpolated, user value uses prepare() placeholder.
        return $wpdb->get_row($wpdb->prepare(
            "SELECT * FROM $table_name WHERE source_url = %s AND active = 1 LIMIT 1",
            $url
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
    }

    /** Cache key holding the active regex rules. */
    const REGEX_CACHE_KEY = 'tam_rank_regex_redirects';

    /**
     * All active regex rules, cached.
     *
     * @return array List of rows with id, source_url, target_url, redirect_type, active.
     */
    private function get_regex_redirects() {
        global $wpdb;

        $cached = get_transient(self::REGEX_CACHE_KEY);
        if (is_array($cached)) {
            return $cached;
        }

        $table_name = $wpdb->prefix . 'tam_rank_redirects';

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); result is cached in a transient below; only the table identifier is interpolated, no user input in the query.
        $rows = $wpdb->get_results(
            "SELECT id, source_url, target_url, redirect_type, active FROM $table_name WHERE match_type = 'regex' AND active = 1 ORDER BY id ASC"
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $rows = is_array($rows) ? $rows : array();
        set_transient(self::REGEX_CACHE_KEY, $rows, DAY_IN_SECONDS);

        return $rows;
    }

    /**
     * Drop the cached regex rules. Called from every write path.
     *
     * @return void
     */
    public static function flush_regex_cache() {
        delete_transient(self::REGEX_CACHE_KEY);
    }

    /**
     * Find the first active regex rule matching this request.
     *
     * @param string $url Request URI.
     * @return array|null array{redirect: object, target_url: string} or null.
     */
    private function match_regex_redirect($url) {
        if (!class_exists('TamRank_Redirect_Regex')) {
            return null;
        }

        $rules = $this->get_regex_redirects();
        if (empty($rules)) {
            return null;
        }

        $subject = self::clean_source_url($url);

        foreach ($rules as $rule) {
            $matches = TamRank_Redirect_Regex::match($rule->source_url, $subject);
            if ($matches === false) {
                continue;
            }

            $target = TamRank_Redirect_Regex::apply_backreferences($rule->target_url, $matches);
            if ($target === '') {
                continue;
            }

            // Does this rule match its own resolved target again? Then firing it
            // is an endless loop, and skipping it is the only sane answer: the
            // visitor gets the ordinary 404 instead of a browser that gives up
            // after twenty hops. The exact branch has had this guard since the
            // bare-path fallback landed (target_is_same_path()); the regex
            // branch never got one, and loop detection is a PRO add-on, so FREE
            // had none at all.
            if ($this->regex_target_loops($rule->source_url, $target)) {
                continue;
            }

            return array('redirect' => $rule, 'target_url' => $target);
        }

        return null;
    }

    /**
     * Would this pattern match its own resolved target?
     *
     * That is the exact shape of a regex self-loop, and it catches both kinds
     * at once: the target that IS the request again (^/lus/(.*)$ -> /lus/$1)
     * and the target that keeps growing (^/(.*)$ -> /doel/$1 puts another
     * /doel in front on every hop). Comparing paths for equality would only
     * see the first kind.
     *
     * A target on another host is never a loop back into this site, so it is
     * left alone — that is the configured-external-host feature, and it has to
     * keep working.
     *
     * @param string $pattern    Stored pattern, without delimiters.
     * @param string $target_url Target with its backreferences already filled in.
     * @return bool
     */
    private function regex_target_loops($pattern, $target_url) {
        $target_host = wp_parse_url($target_url, PHP_URL_HOST);
        if ($target_host) {
            $home_host = wp_parse_url(home_url(), PHP_URL_HOST);
            if (strtolower($target_host) !== strtolower((string) $home_host)) {
                return false;
            }
        }

        // Patterns run against the request URI including its query string, so
        // the target is reduced the same way before it is tried.
        $subject = self::clean_source_url($target_url);

        return TamRank_Redirect_Regex::match($pattern, $subject) !== false;
    }

    /**
     * Refuse a regex rule that would send its own target back to itself.
     *
     * The runtime guard above keeps the visitor out of the loop, but a rule
     * that never fires is exactly the silent failure the pattern mix-up guard
     * exists to prevent: the row looks fine in the table and the traffic is
     * gone. So the loop is caught where the user can still do something about
     * it — at save time, with the example in the message.
     *
     * Judging a pattern needs one concrete request it would match, because the
     * backreferences have to be filled in with something. sample_subject()
     * builds one and returns null for every construct it does not understand,
     * and the sample is verified against the real pattern before it is used.
     * No sample means no verdict: the rule is saved and the runtime guard is
     * what protects the visitor.
     *
     * @param string $pattern    Pattern as it will be stored.
     * @param string $target_url Target as it will be stored, backreferences intact.
     * @return WP_Error|null WP_Error when this rule loops, null otherwise.
     */
    private function regex_loop_error($pattern, $target_url) {
        if (!class_exists('TamRank_Redirect_Regex')) {
            return null;
        }

        $sample = TamRank_Redirect_Regex::sample_subject($pattern);
        if ($sample === null) {
            return null;
        }

        $matches = TamRank_Redirect_Regex::match($pattern, $sample);
        if ($matches === false) {
            return null;
        }

        $resolved = TamRank_Redirect_Regex::apply_backreferences($target_url, $matches);
        if ($resolved === '' || !$this->regex_target_loops($pattern, $resolved)) {
            return null;
        }

        return new WP_Error(
            'regex_loop',
            sprintf(
                /* translators: %1$s is an example request path, %2$s is where that request would be sent. */
                tamrank__('regex_error_loop', 'This pattern sends its own target back to itself, so a visitor would keep being redirected: %1$s would be answered with %2$s, which the pattern matches again. Point the target at a path the pattern does not match.'),
                $sample,
                $resolved
            )
        );
    }

    /**
     * How many of these entries would create_redirect() actually create?
     *
     * The onboarding wizard promises a number and then imports; those two have
     * to be the same number. A count over the source plugin's own list is not
     * that number: create_redirect() refuses a source that is already in the
     * table (an import never overwrites an existing rule), it refuses a source
     * that is really a pattern, and a source that repeats inside the list only
     * ever becomes one row. On an empty table none of the three can happen,
     * which is why the two numbers agreed for as long as they did.
     *
     * Deliberately mirrors create_redirect()'s pre-insert refusals and lives
     * next to it, so a change there has to be followed here. The one refusal
     * it does not reproduce is the tamrank_redirect_presave filter: that is an
     * add-on's veto and running it while merely counting would fire an add-on
     * hook for a write that is not happening.
     *
     * @param array $entries Entries as a migrator hands them over: source, target, type.
     * @return int Rows an import of these entries would add.
     */
    public static function count_creatable_redirects($entries) {
        return count(self::creatable_redirects($entries));
    }

    /**
     * How many of the rules an import would create point at another domain?
     *
     * The import performs those the same as any other rule - handle_redirects()
     * whitelists the configured target's own host through allowed_redirect_hosts
     * before wp_safe_redirect(), so an external target is a working feature and
     * not an accident. What it is not, is obvious: someone moving from another
     * SEO plugin cannot see from the moving plan that part of his traffic will
     * be handed to a domain he no longer owns. So the wizard says the number out
     * loud. Nothing is blocked and nothing is left out of the import.
     *
     * Counted over the SAME set count_creatable_redirects() counts, so the
     * number can never exceed the Redirects category next to it: an entry that
     * is not created cannot send anyone anywhere.
     *
     * A relative target (/new-page) has no host and is never external. A target
     * that still holds a regex backreference has no final host either - the
     * same reason handle_redirects() refuses to whitelist one - and is left out
     * rather than guessed at.
     *
     * @param array $entries Entries as a migrator hands them over: source, target, type.
     * @return int Rules this import would create that leave this site.
     */
    public static function count_external_targets($entries) {
        $own_host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST));

        if ($own_host === '') {
            return 0;
        }

        $external = 0;

        foreach (self::creatable_redirects($entries) as $entry) {
            $host = strtolower((string) wp_parse_url($entry['target'], PHP_URL_HOST));

            if ($host === '' || $host === $own_host || strpos($host, '$') !== false) {
                continue;
            }

            $external++;
        }

        return $external;
    }

    /**
     * The entries an import of this list would actually turn into rows.
     *
     * Single source for every "what does this import really do" question, so a
     * count of the rules and a count of a property of those rules can never
     * disagree about which entries are in play.
     *
     * @param array $entries Entries as a migrator hands them over: source, target, type.
     * @return array[] Accepted entries with their cleaned source, target and type.
     */
    private static function creatable_redirects($entries) {
        global $wpdb;

        $table_name    = $wpdb->prefix . 'tam_rank_redirects';
        $allowed_types = apply_filters('tamrank_redirect_types', array('301', '302', '307', '410', '451'));
        $seen          = array();
        $creatable     = array();

        foreach ((array) $entries as $entry) {
            $source = isset($entry['source']) ? (string) $entry['source'] : '';
            $target = isset($entry['target']) ? (string) $entry['target'] : '';
            $type   = isset($entry['type']) ? (string) $entry['type'] : '301';
            $type   = in_array($type, $allowed_types, true) ? $type : '301';

            // An import is not auto-generated, so the pattern mix-up guard applies.
            if (class_exists('TamRank_Redirect_Regex') && TamRank_Redirect_Regex::looks_like_pattern($source)) {
                continue;
            }

            $source = self::clean_source_url($source);

            if ($source === '' || ($target === '' && self::status_needs_target($type))) {
                continue;
            }

            if (isset($seen[$source])) {
                continue;
            }
            $seen[$source] = true;

            // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); admin-context read, caching not applicable; only the table identifier is interpolated, user value uses prepare() placeholder.
            $existing = $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM $table_name WHERE source_url = %s",
                $source
            ));
            // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

            if ($existing) {
                continue;
            }

            $creatable[] = array(
                'source' => $source,
                'target' => $target,
                'type'   => $type,
            );
        }

        return $creatable;
    }

    /**
     * Log redirect hit
     */
    private function log_redirect_hit($redirect_id) {
        global $wpdb;
        
        $table_name = $wpdb->prefix . 'tam_rank_redirects';
        
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); write operation, caching not applicable; only the table identifier is interpolated, user value uses prepare() placeholder.
        $wpdb->query($wpdb->prepare(
            "UPDATE $table_name SET hits = hits + 1 WHERE id = %d",
            $redirect_id
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
    }
    
    /**
     * Create new redirect
     */
    public function create_redirect($source_url, $target_url, $redirect_type = '301', $auto_generated = false, $match_type = 'exact') {
        global $wpdb;

        $table_name = $wpdb->prefix . 'tam_rank_redirects';

        $match_type = ($match_type === 'regex') ? 'regex' : 'exact';

        if ($match_type === 'regex') {
            // A regex is a pattern, not a URL: do not run it through
            // clean_source_url(), that would mangle it.
            $source_url = trim((string) $source_url);
            $guard      = TamRank_Redirect_Regex::validate($source_url);
            if (is_wp_error($guard)) {
                return $guard;
            }
        } else {
            // A pattern typed while Match is on Exact would be silently
            // mangled by clean_source_url() into a row that can never fire.
            // Only user-typed sources are checked: an auto-generated source
            // is an observed request path, so it is literal by construction.
            if (!$auto_generated && $this->source_looks_like_pattern($source_url)) {
                return new WP_Error('source_looks_like_regex', $this->pattern_mixup_message());
            }

            // Clean source URL
            $source_url = self::clean_source_url($source_url);
        }

        // Allowed redirect status codes. All standard codes are available to
        // every user; the tamrank_redirect_types filter is a generic extension point.
        $allowed_types = apply_filters('tamrank_redirect_types', array('301', '302', '307', '410', '451'));
        $validated_type = in_array($redirect_type, $allowed_types, true) ? $redirect_type : '301';

        // Validate URLs. A non-redirection status has nowhere to send anyone -
        // 410 Gone and 451 Unavailable For Legal Reasons both serve a body and
        // never look at the target - so demanding one here only produced a field
        // the server throws away. Every real redirection has to have somewhere
        // to go. status_needs_target() is the one rule; it asks the code, not a
        // list, so a status added through tamrank_redirect_types is covered too.
        if (empty($source_url) || (empty($target_url) && self::status_needs_target($validated_type))) {
            return new WP_Error('invalid_urls', tamrank__('source_target_required', 'Source and target URL are required'));
        }

        // A pattern that matches its own target is an endless loop for every
        // visitor it catches. Refuse it here, while the user can still fix it.
        if ($match_type === 'regex' && self::status_needs_target($validated_type)) {
            $loop = $this->regex_loop_error($source_url, $target_url);
            if (is_wp_error($loop)) {
                return $loop;
            }
        }

        // Check if redirect already exists
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); admin-context read, caching not applicable; only the table identifier is interpolated, user value uses prepare() placeholder.
        $existing = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM $table_name WHERE source_url = %s",
            $source_url
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

        if ($existing) {
            return new WP_Error('redirect_exists', tamrank__('redirect_already_exists', 'A redirect already exists for this URL'));
        }

        // Let add-ons (e.g. PRO redirect-chain detection) veto the save — for example
        // when it would create an infinite redirect loop. Add-ons return a WP_Error.
        $presave = apply_filters('tamrank_redirect_presave', null, $source_url, $target_url);
        if (is_wp_error($presave)) {
            return $presave;
        }

        // Suppress database errors to prevent them from being displayed
        $suppress = $wpdb->suppress_errors();

        // Insert new redirect.
        // esc_url_raw() strips a "$1" backreference from a regex target, so the
        // regex path keeps the raw (already sanitized) string.
        $stored_target = ($match_type === 'regex') ? $target_url : esc_url_raw($target_url);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query on a custom plugin table (tam_rank_redirects); write operation, caching not applicable.
        $result = $wpdb->insert(
            $table_name,
            array(
                'source_url' => $source_url,
                'target_url' => $stored_target,
                'redirect_type' => $validated_type,
                'match_type' => $match_type,
                'active' => 1,
                'hits' => 0,
                'auto_generated' => $auto_generated ? 1 : 0,
                'created_at' => current_time('mysql')
            ),
            array('%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s')
        );

        // Restore error suppression state
        $wpdb->suppress_errors($suppress);

        if ($result === false) {
            // Check if it was a duplicate key error
            if (strpos($wpdb->last_error, 'Duplicate entry') !== false) {
                return new WP_Error('redirect_exists', tamrank__('redirect_already_exists', 'A redirect already exists for this URL'));
            }
            return new WP_Error('db_error', tamrank__('could_not_create_redirect', 'Could not create the redirect'));
        }

        $redirect_id = $wpdb->insert_id;

        self::flush_regex_cache();

        // Notify add-ons (e.g. PRO redirect-chain detection) that a redirect was saved,
        // so they can maintain their own derived data.
        do_action('tamrank_redirect_saved', $redirect_id, $source_url, $target_url);

        return $redirect_id;
    }
    
    /**
     * Does a rule with this status code need a target URL?
     *
     * Only a real redirection does. handle_redirects() hands anything outside
     * 3xx to serve_non_redirect_status(), which sends the status and renders the
     * 404 template — the target is never read. A 410 Gone and a 451 Unavailable
     * For Legal Reasons therefore have no target to give, and asking for one
     * only leaves noise in the table.
     *
     * Kept as one place so the four entry points (Redirect Manager form, AJAX,
     * CSV import, MCP tool) cannot drift apart on it.
     *
     * @param string|int $redirect_type Validated status code.
     * @return bool True when a target URL is mandatory.
     */
    public static function status_needs_target($redirect_type)
    {
        $status = (int) $redirect_type;

        return ($status >= 300 && $status <= 399);
    }

    /**
     * Does this source look like a regex pattern rather than a path?
     *
     * Thin wrapper so the manager keeps working if the guard class is not
     * loaded for some reason: no guard means no refusal, never a fatal.
     *
     * @param string $source_url
     * @return bool
     */
    private function source_looks_like_pattern($source_url) {
        if (!class_exists('TamRank_Redirect_Regex')) {
            return false;
        }

        return TamRank_Redirect_Regex::looks_like_pattern($source_url);
    }

    /**
     * The message a user gets when their pattern was typed on Exact.
     *
     * It has to say what to do, not just what is wrong: the fix is one
     * dropdown away and the user cannot see the difference in the table.
     *
     * @return string
     */
    private function pattern_mixup_message() {
        return tamrank__(
            'regex_looks_like_pattern',
            'This looks like a regex pattern, but Match is set to Exact. Set Match to Regex to forward every matching path, or enter a plain path such as /old-page.'
        );
    }

    /**
     * Clean source URL
     *
     * Static because the import counter needs it without an instance; the
     * $this-> call sites keep working unchanged.
     */
    private static function clean_source_url($url) {
        // Remove domain if present
        $parsed = wp_parse_url($url);
        if (isset($parsed['path'])) {
            $url = $parsed['path'];
        }
        
        // Ensure starts with /
        if (substr($url, 0, 1) !== '/') {
            $url = '/' . $url;
        }
        
        // Add query string if present
        if (isset($parsed['query'])) {
            $url .= '?' . $parsed['query'];
        }
        
        return $url;
    }
    
    /**
     * Toggle redirect active status
     */
    public function toggle_redirect($redirect_id, $active) {
        global $wpdb;
        
        $table_name = $wpdb->prefix . 'tam_rank_redirects';
        
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query on a custom plugin table (tam_rank_redirects); write operation, caching not applicable.
        $result = $wpdb->update(
            $table_name,
            array('active' => intval($active)),
            array('id' => intval($redirect_id)),
            array('%d'),
            array('%d')
        );

        self::flush_regex_cache();

        return $result !== false;
    }
    
    /**
     * Update an existing redirect's source / target / type.
     *
     * Reuses the same validation as create_redirect (URL cleaning,
     * status-code validation) and excludes the redirect itself from the
     * source-uniqueness check.
     *
     * @param int    $redirect_id
     * @param string $source_url
     * @param string $target_url
     * @param string $redirect_type
     * @return true|WP_Error  true on success, WP_Error on failure
     */
    public function update_redirect($redirect_id, $source_url, $target_url, $redirect_type = '301', $match_type = null) {
        global $wpdb;

        $table_name = $wpdb->prefix . 'tam_rank_redirects';
        $redirect_id = intval($redirect_id);

        if ($redirect_id <= 0) {
            return new WP_Error('invalid_id', tamrank__('invalid_redirect_id', 'Invalid redirect ID'));
        }

        // Verify redirect exists
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); admin-context read, caching not applicable; only the table identifier is interpolated, user value uses prepare() placeholder.
        $existing = $wpdb->get_row($wpdb->prepare(
            "SELECT id, source_url, match_type FROM $table_name WHERE id = %d",
            $redirect_id
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        if (!$existing) {
            return new WP_Error('not_found', tamrank__('redirect_not_found', 'Redirect not found'));
        }

        // Keep the stored match type unless the caller explicitly changes it.
        if ($match_type === null) {
            $match_type = isset($existing->match_type) ? $existing->match_type : 'exact';
        }
        $match_type = ($match_type === 'regex') ? 'regex' : 'exact';

        if ($match_type === 'regex') {
            $source_url = trim((string) $source_url);
            $guard      = TamRank_Redirect_Regex::validate($source_url);
            if (is_wp_error($guard)) {
                return $guard;
            }
        } else {
            // Same mix-up guard as create_redirect(): an edit that puts a
            // pattern back on Exact would break the rule just as quietly.
            if ($this->source_looks_like_pattern($source_url)) {
                return new WP_Error('source_looks_like_regex', $this->pattern_mixup_message());
            }

            $source_url = self::clean_source_url($source_url);
        }

        // Allowed redirect status codes (mirrors create_redirect). All standard
        // codes are available to every user; the filter is a generic extension point.
        $allowed_types = apply_filters('tamrank_redirect_types', array('301', '302', '307', '410', '451'));
        $validated_type = in_array($redirect_type, $allowed_types, true) ? $redirect_type : '301';

        // Same rule as create_redirect(): a 410 or a 451 has nothing to point at,
        // so an edit may clear the target — and switching a 3xx rule to one of
        // them must not trip over the field it no longer needs.
        if (empty($source_url) || (empty($target_url) && self::status_needs_target($validated_type))) {
            return new WP_Error('invalid_urls', tamrank__('source_target_required', 'Source and target URL are required'));
        }

        // Same loop refusal as create_redirect(): an edit can introduce one
        // just as easily as a new rule can.
        if ($match_type === 'regex' && self::status_needs_target($validated_type)) {
            $loop = $this->regex_loop_error($source_url, $target_url);
            if (is_wp_error($loop)) {
                return $loop;
            }
        }

        // Source-uniqueness check, excluding self
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); admin-context read, caching not applicable; only the table identifier is interpolated, user values use prepare() placeholders.
        $collision = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM $table_name WHERE source_url = %s AND id != %d",
            $source_url,
            $redirect_id
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
        if ($collision) {
            return new WP_Error('redirect_exists', tamrank__('redirect_already_exists', 'A redirect already exists for this URL'));
        }

        // Let add-ons (e.g. PRO redirect-chain detection) veto the save (e.g. loops).
        $presave = apply_filters('tamrank_redirect_presave', null, $source_url, $target_url);
        if (is_wp_error($presave)) {
            return $presave;
        }

        $suppress = $wpdb->suppress_errors();

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query on a custom plugin table (tam_rank_redirects); write operation, caching not applicable.
        $result = $wpdb->update(
            $table_name,
            array(
                'source_url' => $source_url,
                // esc_url_raw() would strip a "$1" backreference from a regex target.
                'target_url' => ($match_type === 'regex') ? $target_url : esc_url_raw($target_url),
                'redirect_type' => $validated_type,
                'match_type' => $match_type,
            ),
            array('id' => $redirect_id),
            array('%s', '%s', '%s', '%s'),
            array('%d')
        );

        $wpdb->suppress_errors($suppress);

        if ($result === false) {
            if (strpos($wpdb->last_error, 'Duplicate entry') !== false) {
                return new WP_Error('redirect_exists', tamrank__('redirect_already_exists', 'A redirect already exists for this URL'));
            }
            return new WP_Error('db_error', tamrank__('error_redirect_update', 'Could not update the redirect status.'));
        }

        self::flush_regex_cache();

        // Notify add-ons (e.g. PRO redirect-chain detection) that a redirect was saved.
        do_action('tamrank_redirect_saved', $redirect_id, $source_url, $target_url);

        return true;
    }

    /**
     * Delete redirect
     */
    public function delete_redirect($redirect_id) {
        global $wpdb;
        
        $table_name = $wpdb->prefix . 'tam_rank_redirects';
        
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query on a custom plugin table (tam_rank_redirects); write operation, caching not applicable.
        $result = $wpdb->delete(
            $table_name,
            array('id' => intval($redirect_id)),
            array('%d')
        );

        self::flush_regex_cache();

        return $result !== false;
    }
    
    /**
     * Get all redirects
     */
    public function get_all_redirects($limit = 100, $offset = 0) {
        global $wpdb;
        
        $table_name = $wpdb->prefix . 'tam_rank_redirects';
        
        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); admin-context read, caching not applicable; only the table identifier is interpolated, user values use prepare() placeholders.
        return $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM $table_name ORDER BY created_at DESC LIMIT %d OFFSET %d",
            $limit,
            $offset
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
    }
    
    /**
     * Get redirect statistics
     */
    public function get_redirect_stats() {
        global $wpdb;
        
        $table_name = $wpdb->prefix . 'tam_rank_redirects';
        
        $stats = array();
        
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); admin-context read, caching not applicable; only the table identifier is interpolated, no user values.
        $stats['total_redirects'] = $wpdb->get_var("SELECT COUNT(*) FROM $table_name");
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); admin-context read, caching not applicable; only the table identifier is interpolated, no user values.
        $stats['active_redirects'] = $wpdb->get_var("SELECT COUNT(*) FROM $table_name WHERE active = 1");
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_redirects); admin-context read, caching not applicable; only the table identifier is interpolated, no user values.
        $stats['total_hits'] = $wpdb->get_var("SELECT SUM(hits) FROM $table_name");
        
        return $stats;
    }
    
    /**
     * Import redirects from CSV
     */
    public function import_redirects_from_csv($csv_data) {
        $imported = 0;
        $errors = array();
        
        $lines = explode("\n", $csv_data);
        
        foreach ($lines as $line_number => $line) {
            $line = trim($line);
            if (empty($line) || $line_number === 0) { // Skip header
                continue;
            }
            
            $data = str_getcsv($line);
            
            if (count($data) < 2) {
                $errors[] = "Line " . ($line_number + 1) . ": Invalid format";
                continue;
            }
            
            $source_url = trim($data[0]);
            $target_url = trim($data[1]);
            $redirect_type = isset($data[2]) ? trim($data[2]) : '301';
            // Optional 4th column: exact (default) or regex. Regex rows still go
            // through the full guard in create_redirect().
            $match_type = (isset($data[3]) && strtolower(trim($data[3])) === 'regex') ? 'regex' : 'exact';

            $result = $this->create_redirect($source_url, $target_url, $redirect_type, false, $match_type);
            
            if (is_wp_error($result)) {
                $errors[] = "Line " . ($line_number + 1) . ": " . $result->get_error_message();
            } else {
                $imported++;
            }
        }
        
        return array(
            'imported' => $imported,
            'errors' => $errors
        );
    }
    
    /**
     * Export redirects to CSV
     */
    public function export_redirects_to_csv() {
        $redirects = $this->get_all_redirects(1000); // Get all redirects
        
        $csv = "Source URL,Target URL,Redirect Type,Match Type,Active,Hits,Created\n";
        
        foreach ($redirects as $redirect) {
            // csv_safe() neutralizes spreadsheet formula injection on the
            // free-text URL cells, matching handle_404_csv_download().
            $csv .= sprintf(
                '"%s","%s","%s","%s","%s","%d","%s"' . "\n",
                $this->csv_safe($redirect->source_url),
                $this->csv_safe($redirect->target_url),
                $redirect->redirect_type,
                (isset($redirect->match_type) && $redirect->match_type === 'regex') ? 'regex' : 'exact',
                $redirect->active ? 'Yes' : 'No',
                $redirect->hits,
                $redirect->created_at
            );
        }
        
        return $csv;
    }
    
    /**
     * Clear 404 log
     */
    public function clear_404_log() {
        global $wpdb;
        
        $table_name = $wpdb->prefix . 'tam_rank_404_log';
        
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_404_log); admin-context truncate, caching not applicable; only the table identifier is interpolated, no user values.
        $result = $wpdb->query("TRUNCATE TABLE $table_name");
        
        return $result !== false;
    }
    
    /**
     * AJAX: Create redirect
     */
    public function ajax_create_redirect() {
        check_ajax_referer('tam_rank_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }
        
        $source = sanitize_text_field(wp_unslash($_POST['source'] ?? ''));
        $target = sanitize_url(wp_unslash($_POST['target'] ?? ''));
        $type = sanitize_text_field(wp_unslash($_POST['type'] ?? '301'));
        $match_type = sanitize_key(wp_unslash($_POST['match_type'] ?? 'exact'));

        // A regex pattern is not a URL, so the source must not go through
        // sanitize_url()/sanitize_text_field's URL assumptions; the guard in
        // TamRank_Redirect_Regex::validate() is what vets it.
        $result = $this->create_redirect($source, $target, $type, false, $match_type);

        if (is_wp_error($result)) {
            wp_send_json_error(array('message' => $result->get_error_message(), 'code' => $result->get_error_code() ?: 'request_failed'));
        } else {
            wp_send_json_success('Redirect created successfully');
        }
    }
    
    /**
     * AJAX: Update an existing redirect
     */
    public function ajax_update_redirect() {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        $id = intval($_POST['id'] ?? 0);
        $source = sanitize_text_field(wp_unslash($_POST['source'] ?? ''));
        $target = sanitize_url(wp_unslash($_POST['target'] ?? ''));
        $type = sanitize_text_field(wp_unslash($_POST['type'] ?? '301'));
        $match_type = isset($_POST['match_type']) ? sanitize_key(wp_unslash($_POST['match_type'])) : null;

        $result = $this->update_redirect($id, $source, $target, $type, $match_type);

        if (is_wp_error($result)) {
            wp_send_json_error(array('message' => $result->get_error_message(), 'code' => $result->get_error_code() ?: 'request_failed'));
        }

        $response = array(
            'message' => tamrank__('redirect_updated', 'Redirect updated successfully'),
            'redirect_id' => $id,
        );

        wp_send_json_success($response);
    }

    /**
     * AJAX: Toggle redirect
     */
    public function ajax_toggle_redirect() {
        check_ajax_referer('tam_rank_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }
        
        $id = intval($_POST['id'] ?? 0);
        $active = intval($_POST['active'] ?? 0);
        
        $result = $this->toggle_redirect($id, $active);
        
        if ($result) {
            wp_send_json_success('Redirect status updated');
        } else {
            wp_send_json_error(array('message' => tamrank__('error_redirect_update', 'Could not update the redirect status.'), 'code' => 'save_failed'));
        }
    }
    
    /**
     * AJAX: Delete redirect
     */
    public function ajax_delete_redirect() {
        check_ajax_referer('tam_rank_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }
        
        $id = intval($_POST['id'] ?? 0);
        
        $result = $this->delete_redirect($id);
        
        if ($result) {
            wp_send_json_success('Redirect deleted successfully');
        } else {
            wp_send_json_error(array('message' => tamrank__('error_redirect_delete', 'Could not delete the redirect.'), 'code' => 'save_failed'));
        }
    }
    
    /**
     * AJAX: Clear 404 log
     */
    public function ajax_clear_404_log() {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        $result = $this->clear_404_log();

        if ($result) {
            wp_send_json_success('404 log cleared successfully');
        } else {
            wp_send_json_error(array('message' => tamrank__('error_404_clear', 'Could not clear the 404 log.'), 'code' => 'save_failed'));
        }
    }

    /**
     * AJAX: Ignore 404 URLs — voeg URLs toe aan blocklist en verwijder bestaande log-rijen.
     *
     * De blocklist wordt opgeslagen in option `tam_rank_404_ignored_urls` en
     * gelezen door TamRank_Plugin::should_ignore_404_url() — dus genegeerde URLs
     * worden ook nooit meer opnieuw gelogd bij volgende hits.
     */
    public function ajax_ignore_404_urls() {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        $raw_urls = isset($_POST['urls']) ? map_deep(wp_unslash((array) $_POST['urls']), 'sanitize_text_field') : array();
        $urls = array();
        foreach ($raw_urls as $u) {
            $u = sanitize_text_field(wp_unslash($u));
            if ($u !== '') {
                $urls[] = $u;
            }
        }
        $urls = array_values(array_unique($urls));

        if (empty($urls)) {
            wp_send_json_error(array('message' => tamrank__('error_no_urls_selected', 'No URLs selected.'), 'code' => 'no_input'));
        }

        // Global storage bound to keep the wp_option from bloating — 5000 ignored
        // URLs is plenty; above that the FIFO slice drops the oldest entries. This
        // applies to every install regardless of tier; it is not a paid limit.
        $existing = get_option('tam_rank_404_ignored_urls', array());
        if (!is_array($existing)) {
            $existing = array();
        }
        $merged = array_values(array_unique(array_merge($existing, $urls)));
        if (count($merged) > 5000) {
            $merged = array_slice($merged, -5000);
        }
        update_option('tam_rank_404_ignored_urls', $merged, false);

        // Verwijder de bijbehorende rijen uit de log-tabel zodat ze direct
        // uit de UI verdwijnen en ook niet meer in de count tellen.
        global $wpdb;
        $table_name = $wpdb->prefix . 'tam_rank_404_log';
        $placeholders = implode(',', array_fill(0, count($urls), '%s'));
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the prefix-built table name and a runtime-built %s placeholder string are interpolated; user values use prepare() placeholders; placeholder count matches the $urls args array.
        $sql = $wpdb->prepare(
            "DELETE FROM {$table_name} WHERE url IN ({$placeholders})",
            $urls
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, PluginCheck.Security.DirectDB.UnescapedDBParameter
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_404_log); admin-context write, caching not applicable; $sql is built with $wpdb->prepare() above.
        $deleted = $wpdb->query($sql);

        wp_send_json_success(array(
            'ignored' => count($urls),
            'deleted_rows' => (int) $deleted,
        ));
    }

    /**
     * Direct CSV download handler — triggered via ?page=tam-rank-404&export_404_csv=1
     * Bypasses admin-ajax.php entirely to avoid server-level AJAX blocking.
     */
    public function handle_404_csv_download() {
        if (empty($_GET['export_404_csv']) || empty($_GET['_wpnonce'])) {
            return;
        }

        if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'tam_rank_export_404_csv')) {
            return;
        }

        if (!current_user_can('manage_options')) {
            return;
        }

        global $wpdb;
        $table_name = $wpdb->prefix . 'tam_rank_404_log';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- direct query on a custom plugin table (tam_rank_404_log); admin-context existence check, caching not applicable.
        $table_exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name));
        if (!$table_exists) {
            wp_die(esc_html__('404 log table not found.', 'tam-rank'));
        }

        // Stream CSV directly — no memory buildup
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="404-log-' . gmdate('Y-m-d') . '.csv"');
        header('Pragma: no-cache');
        header('Expires: 0');

        // BOM for Excel UTF-8 — echoed directly to the output stream.
        echo "\xEF\xBB\xBF";
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming a CSV download to php://output, not a filesystem write; WP_Filesystem cannot stream an HTTP response.
        $output = fopen('php://output', 'w');
        fputcsv($output, array('URL', 'Hits', 'Last Seen'));

        $offset = 0;
        $chunk = 500;
        while (true) {
            // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct query on a custom plugin table (tam_rank_404_log); admin-context read, caching not applicable; only the table identifier is interpolated, user values use prepare() placeholders.
            $logs = $wpdb->get_results($wpdb->prepare(
                "SELECT url, COUNT(*) as hits, MAX(timestamp) as last_seen
                 FROM `{$table_name}`
                 GROUP BY url
                 ORDER BY hits DESC
                 LIMIT %d OFFSET %d",
                $chunk,
                $offset
            ));
            // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

            if (empty($logs)) break;

            foreach ($logs as $log) {
                fputcsv($output, array(
                    $this->csv_safe($log->url),
                    intval($log->hits),
                    $log->last_seen,
                ));
            }

            $offset += $chunk;
            if ($offset >= 5000) break;
        }

        // No fclose() needed — php://output is the SAPI output stream, not a real file handle.
        exit;
    }

    /**
     * Neutralise spreadsheet formula injection in CSV cells.
     *
     * Visitor-controlled values (404 url) are written to the export.
     * A cell beginning with = + - @ (or a leading tab/CR) can be interpreted as
     * a formula by Excel/Sheets. Prefix such values with a single quote so they
     * are rendered as literal text. In practice these fields already start with
     * "/" or "http", but this is defence-in-depth against any future field.
     *
     * @param mixed $value Cell value.
     * @return string Safe cell value.
     */
    private function csv_safe($value) {
        $value = (string) $value;
        if ($value !== '' && in_array($value[0], array('=', '+', '-', '@', "\t", "\r"), true)) {
            return "'" . $value;
        }
        return $value;
    }

    /**
     * AJAX: Export redirects to CSV
     */
    public function ajax_export_redirects() {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        $csv = $this->export_redirects_to_csv();
        $stats = $this->get_redirect_stats();

        wp_send_json_success(array(
            'csv' => $csv,
            'filename' => 'tam-rank-redirects-' . gmdate('Y-m-d') . '.csv',
            'total' => $stats['total_redirects']
        ));
    }

    /**
     * AJAX: Import redirects from CSV
     */
    public function ajax_import_redirects() {
        check_ajax_referer('tam_rank_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array('message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'), 'code' => 'permission_denied'));
        }

        if (!isset($_POST['csv_data']) || empty($_POST['csv_data'])) {
            wp_send_json_error(array('message' => tamrank__('error_no_csv_data', 'No CSV data received.'), 'code' => 'missing_parameter'));
        }

        $csv_data = sanitize_textarea_field(wp_unslash($_POST['csv_data']));
        $result = $this->import_redirects_from_csv($csv_data);

        if ($result['imported'] > 0 || empty($result['errors'])) {
            wp_send_json_success(array(
                'imported' => $result['imported'],
                'errors' => $result['errors'],
                'message' => sprintf(/* translators: %d: number of redirects */ __('%d redirects imported successfully', 'tam-rank'), $result['imported'])
            ));
        } else {
            wp_send_json_error(array(
                'imported' => 0,
                'errors' => $result['errors'],
                'message' => __('Import failed:', 'tam-rank') . ' ' . implode(', ', array_slice($result['errors'], 0, 3)),
                'code' => 'save_failed',
            ));
        }
    }

}