# 404-solution/4.2.0/includes/EngineProfileResolver.php

404 Solution, version 4.2.0. 404 lines.

- Page: https://pluginprobe.com/plugins/404-solution/4.2.0/code/includes/EngineProfileResolver.php
- Raw: https://pluginprobe.com/plugins/404-solution/4.2.0/raw/includes/EngineProfileResolver.php
- Modified: 2026-05-24T08:07:28+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/404-solution/4.2.0/code/includes/EngineProfileResolver.php#L10-L20`.

```php
<?php

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

/**
 * Engine Profile Resolver
 *
 * Allows admins to create URL-pattern-based "engine profiles" that control
 * which matching engines run for a given 404 URL. First matching profile wins.
 * If no profile matches, all engines are used (default behavior preserved).
 *
 * Profiles are stored in wp_abj404_engine_profiles and cached per-request
 * via a static property to avoid repeated DB queries on the same page.
 */
class ABJ_404_Solution_EngineProfileResolver {

    /** @var self|null */
    private static $instance = null;

    /** @var array<int, object>|null Cached profile rows for current request */
    private $cachedProfiles = null;

    /** @var ABJ_404_Solution_DataAccess|null Lazy DAO accessor for centralized query handling. */
    /** @var ABJ_404_Solution_DatabaseCore|null */
    private $dbCore = null;

    /** @return self */
    public static function getInstance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Lazy dbCore accessor, defers resolution until first use so unit tests
     * that exercise pure-logic methods (URL matching, JSON decoding) don't
     * boot the database layer.
     *
     * @return ABJ_404_Solution_DatabaseCore
     */
    private function dbCore() {
        if ($this->dbCore === null) {
            $this->dbCore = abj_service('db_core');
        }
        return $this->dbCore;
    }

    /**
     * Resolve which engines to run for a given 404 URL.
     *
     * Queries active profiles ordered by priority ASC. First profile whose
     * url_pattern matches the requested URL wins. The matched profile's
     * enabled_engines list (JSON array of full class names) is used to filter
     * the $allEngines array.
     *
     * If no profile matches, $allEngines is returned unchanged.
     *
     * The result is passed through `apply_filters('abj404_resolved_engines', ...)`.
     *
     * @param string $requestedURL The 404 URL being processed (path only or full URL).
     * @param array<int, mixed> $allEngines The full list of matching engine instances.
     * @return array<int, mixed> Filtered (or unchanged) engine list.
     */
    public function resolve(string $requestedURL, array $allEngines): array {
        if (empty($allEngines)) {
            return $allEngines;
        }

        $profiles = $this->getActiveProfiles();

        if (empty($profiles)) {
            return $allEngines;
        }

        foreach ($profiles as $profile) {
            if ($this->urlMatchesProfile($requestedURL, $profile)) {
                $filtered = $this->filterEnginesByProfile($allEngines, $profile);
                /** @var array<int, mixed> $filtered */
                $filtered = apply_filters('abj404_resolved_engines', $filtered, $requestedURL, $profile);
                return $filtered;
            }
        }

        // No match — return all engines unchanged.
        return $allEngines;
    }

    /**
     * Determine whether a specific engine class is enabled for a requested URL.
     *
     * This mirrors profile matching + fail-open behavior used by resolve():
     * - no matching profile -> enabled
     * - empty/malformed enabled_engines -> enabled
     * - matching profile with explicit enabled_engines -> enabled only if listed
     *
     * @param string $requestedURL
     * @param string $engineClassName Fully-qualified class name or short suffix.
     * @return bool
     */
    public function isEngineEnabledForUrl(string $requestedURL, string $engineClassName): bool {
        $profiles = $this->getActiveProfiles();

        if (empty($profiles)) {
            return true;
        }

        foreach ($profiles as $profile) {
            if (!$this->urlMatchesProfile($requestedURL, $profile)) {
                continue;
            }

            $enabledLower = $this->decodeEnabledEnginesLower($profile);
            if ($enabledLower === null || empty($enabledLower)) {
                // Fail-open to preserve historical behavior for broken/empty config.
                return true;
            }

            return $this->classMatchesEnabledList($engineClassName, $enabledLower);
        }

        // No profile matched this URL.
        return true;
    }

    /**
     * Load all active profiles ordered by priority ASC (lowest priority number = first).
     *
     * Uses a per-request cache to avoid repeated DB queries.
     *
     * @return array<int, object>
     */
    private function getActiveProfiles(): array {
        if ($this->cachedProfiles !== null) {
            return $this->cachedProfiles;
        }

        $table = $this->getTableName();

        if (!$this->tableExists($table)) {
            $this->cachedProfiles = [];
            return $this->cachedProfiles;
        }

        $queryResult = $this->dbCore()->queryAndGetResults(
            "SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`
             FROM `{$table}`
             WHERE `status` = 1
             ORDER BY `priority` ASC, `id` ASC
             LIMIT %d",
            ['query_params' => [200], 'result_type' => OBJECT]
        );
        $rows = $queryResult['rows'] ?? [];

        $this->cachedProfiles = is_array($rows) ? $rows : [];
        return $this->cachedProfiles;
    }

    /**
     * Check whether a URL matches a profile's pattern.
     *
     * @param string $url
     * @param object $profile
     * @return bool
     */
    private function urlMatchesProfile(string $url, object $profile): bool {
        $pattern = isset($profile->url_pattern) ? (string)$profile->url_pattern : '';

        if ($pattern === '') {
            return false;
        }

        $isRegex = isset($profile->is_regex) && (int)$profile->is_regex === 1;

        if ($isRegex) {
            // Patterns are stored without PHP delimiters (users write ^/shop/, not #^/shop/#).
            // Auto-wrap with # delimiters unless the pattern already starts with one.
            $commonDelimiters = ['/', '#', '~', '!', '@', '|', '%'];
            if (!in_array(substr($pattern, 0, 1), $commonDelimiters, true)) {
                $pattern = '#' . $pattern . '#';
            }
            // Suppress errors to prevent site breakage from malformed patterns.
            set_error_handler(function (int $errno, string $errstr, string $errfile = '', int $errline = 0): bool { return false; }, E_WARNING);
            $matched = @preg_match($pattern, $url);
            restore_error_handler();
            return $matched === 1;
        }

        // Non-regex: fnmatch-style pattern with * as wildcard, case-insensitive.
        // fnmatch is not available on all Windows PHP builds, so fall back to
        // a manual conversion via the approach used by the rest of the codebase.
        if (function_exists('fnmatch')) {
            return fnmatch($pattern, $url, FNM_CASEFOLD | FNM_PATHNAME) ||
                   fnmatch($pattern, $url, FNM_CASEFOLD);
        }

        // Fallback: convert * wildcard to a regex.
        $regex = '/^' . str_replace(
            ['\\*', '\\?'],
            ['.*', '.'],
            preg_quote($pattern, '/')
        ) . '$/i';
        return (bool)preg_match($regex, $url);
    }

    /**
     * Filter $allEngines to only those listed in the profile's enabled_engines JSON.
     *
     * If enabled_engines is empty or malformed, all engines are returned (fail-open).
     *
     * @param array<int, mixed> $allEngines
     * @param object $profile
     * @return array<int, mixed>
     */
    private function filterEnginesByProfile(array $allEngines, object $profile): array {
        $enabledLower = $this->decodeEnabledEnginesLower($profile);
        if ($enabledLower === null || empty($enabledLower)) {
            // Empty/malformed list — no restriction (fail-open).
            return $allEngines;
        }

        $filtered = array_values(array_filter($allEngines, function ($engine) use ($enabledLower) {
            if (!is_object($engine)) {
                return false;
            }
            return $this->classMatchesEnabledList(get_class($engine), $enabledLower);
        }));

        return $filtered;
    }

    /**
     * Parse enabled_engines JSON and normalize class names to lowercase.
     *
     * @param object $profile
     * @return array<int, string>|null Null when empty/missing/malformed.
     */
    private function decodeEnabledEnginesLower(object $profile): ?array {
        $json = isset($profile->enabled_engines) ? (string)$profile->enabled_engines : '';
        if (trim($json) === '') {
            return null;
        }

        $enabledClassNames = json_decode($json, true);
        if (!is_array($enabledClassNames) || empty($enabledClassNames)) {
            return null;
        }

        $enabledLower = array_values(array_filter(array_map(function ($name) {
            return is_scalar($name) ? strtolower((string)$name) : '';
        }, $enabledClassNames), function ($name) {
            return $name !== '';
        }));

        return empty($enabledLower) ? null : $enabledLower;
    }

    /**
     * Case-insensitive class-name check with suffix matching compatibility.
     *
     * @param string $className
     * @param array<int, string> $enabledLower
     * @return bool
     */
    private function classMatchesEnabledList(string $className, array $enabledLower): bool {
        $classLower = strtolower($className);
        foreach ($enabledLower as $allowed) {
            if ($classLower === $allowed || substr($classLower, -strlen($allowed)) === $allowed) {
                return true;
            }
        }
        return false;
    }

    /**
     * @return string The fully-prefixed table name.
     */
    private function getTableName(): string {
        global $wpdb;
        return strtolower($wpdb->prefix) . 'abj404_engine_profiles';
    }

    /**
     * Check if the engine profiles table exists in the current database.
     *
     * @param string $table
     * @return bool
     */
    private function tableExists(string $table): bool {
        $queryResult = $this->dbCore()->queryAndGetResults(
            'SHOW TABLES LIKE %s',
            ['query_params' => [$table]]
        );
        $rows = isset($queryResult['rows']) && is_array($queryResult['rows']) ? $queryResult['rows'] : [];
        $first = $rows[0] ?? null;
        if (!is_array($first)) {
            return false;
        }
        $firstValue = reset($first);
        return $firstValue === $table;
    }

    /**
     * Insert or update a profile row.
     *
     * @param array<string, mixed> $data
     * @return int|false Inserted/updated row ID, or false on failure.
     */
    public function saveProfile(array $data) {
        $table = $this->getTableName();

        $id = isset($data['id']) && is_numeric($data['id']) ? (int)$data['id'] : 0;

        $name            = isset($data['name'])            ? sanitize_text_field(is_string($data['name']) ? $data['name'] : '')                                 : '';
        $urlPattern      = isset($data['url_pattern'])     ? wp_unslash(is_string($data['url_pattern']) ? $data['url_pattern'] : '')                           : '';
        $isRegex         = isset($data['is_regex'])        ? (int)(bool)$data['is_regex']                                                                      : 0;
        $enabledEngines  = isset($data['enabled_engines']) ? (is_string($data['enabled_engines']) ? $data['enabled_engines'] : '[]')                           : '[]';
        $priority        = isset($data['priority'])        ? (is_numeric($data['priority']) ? (int)$data['priority'] : 0)                                      : 0;
        $status          = isset($data['status'])          ? (int)(bool)$data['status']                                                                        : 1;

        // Validate enabled_engines is valid JSON array.
        $decoded = json_decode($enabledEngines, true);
        if (!is_array($decoded)) {
            $enabledEngines = '[]';
        }

        if ($id > 0) {
            $queryResult = $this->dbCore()->queryAndGetResults(
                "UPDATE `{$table}`
                    SET `name` = %s, `url_pattern` = %s, `is_regex` = %d,
                        `enabled_engines` = %s, `priority` = %d, `status` = %d
                  WHERE `id` = %d",
                ['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status, $id]]
            );
            $this->cachedProfiles = null;
            $updateError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : '';
            return $updateError === '' ? $id : false;
        }

        $queryResult = $this->dbCore()->queryAndGetResults(
            "INSERT INTO `{$table}` (`name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status`)
                  VALUES (%s, %s, %d, %s, %d, %d)",
            ['query_params' => [$name, $urlPattern, $isRegex, $enabledEngines, $priority, $status]]
        );
        $this->cachedProfiles = null;
        $lastError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : '';
        if ($lastError !== '') {
            return false;
        }
        $insertId = isset($queryResult['insert_id']) && is_scalar($queryResult['insert_id']) ? (int)$queryResult['insert_id'] : 0;
        return $insertId > 0 ? $insertId : false;
    }

    /**
     * Delete a profile by ID.
     *
     * @param int $id
     * @return bool
     */
    public function deleteProfile(int $id): bool {
        $table = $this->getTableName();
        $queryResult = $this->dbCore()->queryAndGetResults(
            "DELETE FROM `{$table}` WHERE `id` = %d",
            ['query_params' => [$id]]
        );
        $this->cachedProfiles = null;
        $deleteError = isset($queryResult['last_error']) && is_string($queryResult['last_error']) ? $queryResult['last_error'] : '';
        return $deleteError === '';
    }

    /**
     * Return all profiles (including inactive) for admin display.
     *
     * @return array<int, array<string, mixed>>
     */
    public function getAllProfilesForAdmin(): array {
        $table = $this->getTableName();

        if (!$this->tableExists($table)) {
            return [];
        }

        $queryResult = $this->dbCore()->queryAndGetResults(
            "SELECT `id`, `name`, `url_pattern`, `is_regex`, `enabled_engines`, `priority`, `status`
             FROM `{$table}`
             ORDER BY `priority` ASC, `id` ASC"
        );
        $rows = $queryResult['rows'] ?? [];

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

    /**
     * Reset the request-level cache (used in tests).
     *
     * @return void
     */
    public function clearCache(): void {
        $this->cachedProfiles = null;
    }
}

```
