<?php
/**
 * Tam Rank™ — is this logged 404 already answered by a redirect?
 *
 * The 404 log is history: every hit stays in the table, also after the user
 * fixes the URL. What has to disappear is the *notification* — the dashboard
 * task, the counters on the 404 Monitor and the agent-facing has_redirect flag.
 * So this class filters, it never deletes. Switch a rule off and the 404 comes
 * straight back into view, which is exactly the behaviour we want: an inactive
 * rule does not fix anything.
 *
 * ONE definition, four consumers: the dashboard task (admin/dashboard.php), the
 * 404 Monitor list + counters (admin/settings-page.php), the site-wide 404
 * counter (tam_rank_get_404_count()) and the PRO MCP has_redirect flag. Two
 * hand-written versions of "is this 404 resolved" drift apart within a release,
 * and a wrong answer here is worse than the bug it fixes: a 404 that is still
 * genuinely broken would vanish from the only screen that reports it.
 *
 * ── What counts as resolved ───────────────────────────────────────────────
 *
 * Exactly what handle_redirects() would actually serve. That matcher has four
 * steps, and a 404 can be answered through any of them:
 *
 *   1. the full request URI, query string included (a source may be stored with
 *      its own query string);
 *   2. the bare path, so /old/?utm_source=x is answered by the rule /old/ —
 *      unless the target resolves to that same path, which the matcher refuses
 *      because it would loop;
 *   3. a regex rule, run against the full URI;
 *   4. the trailing-slash twin of the bare path, guarded by is_404() — so a
 *      rule stored as /old also answers /old/, and a log row /old/ is resolved.
 *
 * Steps 1, 2 and 4 are pure string comparisons against an indexed, unique
 * column, so they live in SQL (unresolved_sql()) and cost no extra query. Step 3
 * cannot: a PCRE has no SQL equivalent that is safe to translate, so it runs in
 * PHP (regex_catches()) over the cached rule set — and only when the site has
 * active regex rules at all, which for almost every install means never.
 *
 * The SQL and the PHP are two encodings of one rule, because you cannot COUNT()
 * in PHP without fetching every row first. docs/404-resolutie-harness.php
 * asserts on every case that both encodings answer the same thing.
 *
 * ── Known limit ───────────────────────────────────────────────────────────
 *
 * is_404() (step 4) is a request-time fact and cannot be replayed from the log.
 * A logged URL was a 404 when it was logged; if the site has real content there
 * now, the row is stale either way and the rule can no longer hijack it — the
 * matcher's own guard sees to that.
 *
 * @package TamRank
 * @since 1.3.7
 */

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

class TamRank_404_Resolution
{
    /**
     * SQL predicate: this logged URL is NOT answered by any active exact rule.
     *
     * Drops into the WHERE of any query over the 404 log. Adds no query of its
     * own: source_url is a UNIQUE column, so each candidate spelling is a single
     * index lookup.
     *
     * Deliberately does NOT filter on match_type. get_redirect_for_url() does
     * not either — it matches source_url on whatever is stored — so a rule saved
     * as regex whose pattern happens to be requested literally would fire, and
     * this predicate says the same. Mirroring the matcher beats tidying it.
     *
     * @param string $url_expr SQL expression holding the logged URL, already
     *                         safe to interpolate: a column name ('url',
     *                         'l.url') or a $wpdb->prepare()-quoted literal.
     *                         Never raw user input.
     * @return string SQL fragment, safe to concatenate into a WHERE.
     */
    public static function unresolved_sql($url_expr = 'url')
    {
        global $wpdb;

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

        // The bare path: everything before the first '?'. This is what the
        // matcher compares in steps 2 and 4.
        $path = "SUBSTRING_INDEX({$url_expr}, '?', 1)";

        // The trailing-slash twin, mirroring toggle_trailing_slash(): the site
        // root has no twin worth trying, and neither does a path that would
        // collapse into it. NULL means "no twin", and NULL never matches in IN().
        $twin = "CASE
                    WHEN {$path} IN ('', '/') THEN NULL
                    WHEN RIGHT({$path}, 1) = '/' THEN
                        CASE WHEN LEFT({$path}, CHAR_LENGTH({$path}) - 1) IN ('', '/') THEN NULL
                             ELSE LEFT({$path}, CHAR_LENGTH({$path}) - 1) END
                    ELSE CONCAT({$path}, '/')
                 END";

        // The rule's target reduced to a path, for the loop guard below. Strips
        // scheme + host when present, then the query string and the fragment.
        // Stricter than target_is_same_path(), which lets an external host off
        // the hook: being stricter here can only keep a 404 visible, never hide
        // one, and that is the safe direction to be wrong in.
        $target_path = "SUBSTRING_INDEX(SUBSTRING_INDEX(
                            CASE WHEN LOCATE('://', tr_res.target_url) > 0
                                 THEN SUBSTRING(tr_res.target_url, LOCATE('/', tr_res.target_url, LOCATE('://', tr_res.target_url) + 3))
                                 ELSE tr_res.target_url END
                        , '?', 1), '#', 1)";

        // Step 1 is unconditional: an exact source hit is served as-is. Steps 2
        // and 4 are fallbacks, and the matcher refuses both when the target
        // resolves to the very path that was requested — that rule would bounce
        // forever, so it does not resolve anything.
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $url_expr is a column name or an already-prepared literal supplied by plugin code; every other value here is a constant.
        return "NOT EXISTS (
                    SELECT 1 FROM {$redirects} tr_res
                    WHERE tr_res.active = 1
                      AND (
                            tr_res.source_url = {$url_expr}
                         OR ( tr_res.source_url IN ({$path}, {$twin}) AND {$target_path} <> {$path} )
                      )
                )";
    }

    /**
     * Is this logged 404 answered by an active rule — exact or regex?
     *
     * One query, plus the cached regex walk. Use resolved_map() for a list.
     *
     * @param string $url Logged 404 URL, as stored in the log.
     * @return bool
     */
    public static function is_resolved($url)
    {
        global $wpdb;

        $url = (string) $url;
        if ($url === '') {
            return false;
        }

        $literal = $wpdb->prepare('%s', $url);

        // SELECT <predicate> returns 1 when the URL survives the exact rules.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct read on a custom plugin table (tam_rank_redirects) through the shared predicate; the only user value is bound with prepare() above; admin/agent-context read, caching not applicable.
        $unresolved = (int) $wpdb->get_var('SELECT ' . self::unresolved_sql($literal));

        if ($unresolved !== 1) {
            return true;
        }

        return self::regex_catches($url);
    }

    /**
     * Resolution status for a list of logged URLs, in a fixed number of queries.
     *
     * Built for row lists (the MCP flag, a rendered table): one query for the
     * exact rules regardless of list length, then the cached regex walk.
     *
     * @param string[] $urls Logged 404 URLs.
     * @return array<string,bool> url => true when a redirect already answers it.
     */
    public static function resolved_map($urls)
    {
        global $wpdb;

        $map = array();
        $urls = array_values(array_unique(array_filter(array_map('strval', (array) $urls), 'strlen')));
        if (empty($urls)) {
            return $map;
        }

        foreach ($urls as $url) {
            $map[$url] = false;
        }

        // One row per candidate URL, fed through the same predicate. A UNION of
        // literals keeps this to a single round trip whatever the list size,
        // and keeps the exact rules defined in exactly one place.
        $selects = array();
        foreach ($urls as $url) {
            $selects[] = $wpdb->prepare('SELECT %s AS tr_url', $url);
        }
        $subject = implode(' UNION ALL ', $selects);

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- direct read on a custom plugin table (tam_rank_redirects) through the shared predicate; every user value is bound with prepare() above; admin/agent-context read, caching not applicable.
        $rows = $wpdb->get_results(
            'SELECT tr_subject.tr_url AS tr_url, ' . self::unresolved_sql('tr_subject.tr_url') . ' AS tr_unresolved
             FROM (' . $subject . ') tr_subject'
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

        $still_open = array();
        foreach ((array) $rows as $row) {
            if ((int) $row->tr_unresolved === 1) {
                $still_open[] = (string) $row->tr_url;
            } else {
                $map[(string) $row->tr_url] = true;
            }
        }

        foreach (self::regex_resolved($still_open) as $url) {
            $map[$url] = true;
        }

        return $map;
    }

    /**
     * Drop the URLs a regex rule already answers.
     *
     * Separated out so a caller that has already run the SQL predicate (a list
     * query, a counter) can finish the job without repeating the exact part.
     *
     * @param string[] $urls URLs that survived the exact rules.
     * @return string[] The subset that is genuinely still broken.
     */
    public static function drop_regex_resolved($urls)
    {
        $urls = array_values((array) $urls);
        if (empty($urls) || !self::has_regex_rules()) {
            return $urls;
        }

        $resolved = array_flip(self::regex_resolved($urls));

        $open = array();
        foreach ($urls as $url) {
            if (!isset($resolved[(string) $url])) {
                $open[] = $url;
            }
        }

        return $open;
    }

    /**
     * Which of these URLs does an active regex rule answer?
     *
     * @param string[] $urls
     * @return string[]
     */
    private static function regex_resolved($urls)
    {
        $hit = array();
        if (empty($urls) || !self::has_regex_rules()) {
            return $hit;
        }

        foreach ($urls as $url) {
            if (self::regex_catches($url)) {
                $hit[] = (string) $url;
            }
        }

        return $hit;
    }

    /**
     * Does an active regex rule match this URL?
     *
     * Mirrors match_regex_redirect(): the pattern runs against the full URI,
     * query string included, and a rule whose target resolves to nothing is
     * skipped there too — so it does not resolve anything here either.
     *
     * @param string $url Logged 404 URL.
     * @return bool
     */
    public static function regex_catches($url)
    {
        if (!class_exists('TamRank_Redirect_Regex')) {
            return false;
        }

        $rules = self::regex_rules();
        if (empty($rules)) {
            return false;
        }

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

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

        return false;
    }

    /** Does this site have any active regex rule at all? */
    public static function has_regex_rules()
    {
        $rules = self::regex_rules();
        return !empty($rules);
    }

    /**
     * The active regex rules, from the manager's own cache.
     *
     * Reads and fills TamRank_Redirect_Manager::REGEX_CACHE_KEY, so there is one
     * cached rule set on the site and every write path that flushes it for the
     * matcher flushes it for this class too.
     *
     * @return array Rows with id, source_url, target_url.
     */
    private static function regex_rules()
    {
        global $wpdb;

        $key = class_exists('TamRank_Redirect_Manager')
            ? TamRank_Redirect_Manager::REGEX_CACHE_KEY
            : 'tam_rank_regex_redirects';

        $cached = get_transient($key);
        if (is_array($cached)) {
            return $cached;
        }

        $table = $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 the 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} 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($key, $rows, DAY_IN_SECONDS);

        return $rows;
    }

    /**
     * The subject a regex rule is matched against.
     *
     * Mirrors clean_source_url(): path plus its own query string, with a leading
     * slash guaranteed. The log stores REQUEST_URI, so this is normally a no-op.
     *
     * @param string $url
     * @return string
     */
    private static function clean_subject($url)
    {
        $url    = (string) $url;
        $parsed = wp_parse_url($url);

        $path = isset($parsed['path']) ? $parsed['path'] : $url;
        if (substr($path, 0, 1) !== '/') {
            $path = '/' . $path;
        }
        if (isset($parsed['query'])) {
            $path .= '?' . $parsed['query'];
        }

        return $path;
    }
}
