<?php
/**
 * This file is a part of the CIDRAM package.
 * Homepage: https://cidram.github.io/
 *
 * CIDRAM COPYRIGHT 2016 and beyond by Caleb Mazalevskis (Maikuolan).
 *
 * License: GNU/GPLv2
 * @see LICENSE.txt
 *
 * This file: The CIDRAM core (last modified: 2026.02.22).
 */

namespace CIDRAM\CIDRAM;

class Core
{
    use Expand;
    use Protect;

    /**
     * @var string The path to CIDRAM's configuration file.
     */
    public $ConfigurationPath = '';

    /**
     * @var array CIDRAM's configuration data.
     */
    public $Configuration = [];

    /**
     * @var array The path to the vault directory.
     */
    public $Vault = '';

    /**
     * @var array The path for signature files.
     */
    public $SignaturesPath = '';

    /**
     * @var array The path for modules.
     */
    public $ModulesPath = '';

    /**
     * @var array The path for imports.
     */
    public $ImportsPath = '';

    /**
     * @var array The path for events.
     */
    public $EventsPath = '';

    /**
     * @var array The path for assets.
     */
    public $AssetsPath = '';

    /**
     * @var array The path to use for flatfile caching if enabled.
     */
    public $CachePath = '';

    /**
     * @var \Maikuolan\Common\YAML An object for handling YAML data.
     */
    public $YAML;

    /**
     * @var \Maikuolan\Common\Events An object for orchestrating events.
     */
    public $Events;

    /**
     * @var \Maikuolan\Common\Request An object for sending cURL requests.
     */
    public $Request;

    /**
     * @var \Maikuolan\Common\L10N An object for handling configuration-defined L10N data.
     */
    public $L10N;

    /**
     * @var string Which configuration-defined language was accepted by CIDRAM.
     */
    public $L10NAccepted = '';

    /**
     * @var \Maikuolan\Common\L10N An object for handling client-defined L10N data.
     */
    public $ClientL10N;

    /**
     * @var string Which client-defined language was accepted by CIDRAM (if any).
     */
    public $ClientL10NAccepted = '';

    /**
     * @var \Maikuolan\Common\Cache An object for handling cache data.
     */
    public $Cache;

    /**
     * @var \Maikuolan\Common\NumberFormatter Used to format numbers according
     *      to the specified configuration.
     */
    public $NumberFormatter;

    /**
     * @var \Maikuolan\Common\Demojibakefier Used to ensure correct encoding,
     *      hide bad data, etc.
     */
    public $Demojibakefier;

    /**
     * @var \Maikuolan\Common\Operation Needed for checking dependency version
     *      constraints, for imports, for processing import and module
     *      warnings, and for auxiliary rules additional instructions.
     */
    public $OperationHandler;

    /**
     * @var \CIDRAM\CIDRAM\Reporter Instantiate report orchestrator (used by some modules).
     */
    public $Reporter;

    /**
     * @var string CIDRAM version number (SemVer).
     */
    public $ScriptVersion = '3.10.3';

    /**
     * @var string CIDRAM version identifier (complete notation).
     */
    public $ScriptIdent = 'CIDRAM v%s';

    /**
     * @var string CIDRAM user agent (for external requests).
     */
    public $ScriptUA = '%s (https://cidram.github.io/)';

    /**
     * @var int When the object was instantiated.
     */
    public $Now = 0;

    /**
     * @var array Used for logging any errors generated by CIDRAM.
     */
    public $Errors = [];

    /**
     * @var string The IP address the instance is working with.
     */
    public $ipAddr = '';

    /**
     * @var array For any working data without dedicated properties.
     */
    public $CIDRAM = [];

    /**
     * @var array Contains information about a block event.
     */
    public $BlockInfo = [];

    /**
     * @var string The current stage of the execution chain.
     */
    public $Stage = '';

    /**
     * @var array The stages of the execution chain.
     */
    public $Stages = [];

    /**
     * @var array Shorthand options affect how standard signatures behave.
     */
    public $Shorthand = [];

    /**
     * @var array The statistics to be tracked.
     */
    public $StatisticsTracked = [];

    /**
     * @var array The current statistics.
     */
    public $Statistics = [];

    /**
     * @var array Request profiling to provide greater nuance for block events.
     */
    public $Profiles = [];

    /**
     * @var array Sometimes used with certain kinds of blocked requests.
     */
    public $Webhooks = [];

    /**
     * @var array Tokens from user agents and etc.
     */
    public $Tokens = [];

    /**
     * @var array Channels information for request.
     */
    public $Channels = [];

    /**
     * @var string The default hashing algorithm for CIDRAM to use.
     */
    public $DefaultAlgo = '';

    /**
     * @var int Default file blocksize (128KB).
     */
    public const FILE_BLOCKSIZE = 131072;

    /**
     * @var int Minimum salt length (used by the generateSalt() method).
     */
    public const GENERATE_SALT_MIN_LEN = 32;

    /**
     * @var int Maximum salt length (used by the generateSalt() method).
     */
    public const GENERATE_SALT_MAX_LEN = 72;

    /**
     * @var int Earliest permitted byte (used by the generateSalt() method).
     */
    public const GENERATE_SALT_MIN_CHR = 1;

    /**
     * @var int Latest permitted byte (used by the generateSalt() method).
     */
    public const GENERATE_SALT_MAX_CHR = 255;

    /**
     * @var int Used for some logs page and range tables calculations.
     */
    public const MAX_BLOCKSIZE = 65536;

    /**
     * @var string Used by dnsResolve() when padding out Google DNS requests.
     */
    private const PAD_FOR_DNS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~';

    /**
     * Construct the CIDRAM core.
     *
     * @param string $ConfigurationPath An optional custom path to the CIDRAM
     *      configuration file.
     * @param string $CachePath An optional custom path to the CIDRAM cache
     *      file (only has an effect when using flatfile caching).
     * @return void
     */
    public function __construct(string $ConfigurationPath = '', string $CachePath = '')
    {
        /** Vault directory. */
        $this->Vault = $this->canonical(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR);

        /** Signatures path. */
        $this->SignaturesPath = $this->Vault . 'signatures' . DIRECTORY_SEPARATOR;

        /** Modules path. */
        $this->ModulesPath = $this->Vault . 'modules' . DIRECTORY_SEPARATOR;

        /** Imports path. */
        $this->ImportsPath = $this->Vault . 'imports' . DIRECTORY_SEPARATOR;

        /** Events path. */
        $this->EventsPath = $this->Vault . 'events' . DIRECTORY_SEPARATOR;

        /** Assets path. */
        $this->AssetsPath = $this->Vault . 'assets' . DIRECTORY_SEPARATOR;

        /** Instantiate YAML object for accessing data reconstruction and processing various YAML files. */
        $this->YAML = new \Maikuolan\Common\YAML();

        /** Instantiate events orchestrator in order to allow malleable logging and etc. */
        $this->Events = new \Maikuolan\Common\Events();

        /** CIDRAM version identifier (complete notation). */
        $this->ScriptIdent = 'CIDRAM v' . $this->ScriptVersion;

        /** CIDRAM User Agent (for external requests). */
        $this->ScriptUA = $this->ScriptIdent . ' (https://cidram.github.io/)';

        /** Fetch domain segment of HTTP_HOST (needed for writing cookies safely). */
        $this->CIDRAM['HTTP_HOST'] = empty($_SERVER['HTTP_HOST']) ? '' : (
            strpos($_SERVER['HTTP_HOST'], ':') === false ? $_SERVER['HTTP_HOST'] : substr($_SERVER['HTTP_HOST'], 0, strpos($_SERVER['HTTP_HOST'], ':'))
        );

        /** Allow post override of HTTP_HOST (assists with proxied front-end pages). */
        $this->CIDRAM['HostnameOverride'] = $_POST['hostname'] ?? '';

        /** Checks whether the CIDRAM defaults file is readable. */
        if (!is_readable($this->Vault . 'defaults.yml')) {
            header('Content-Type: text/plain');
            die('[CIDRAM] Can\'t read the defaults file! Can\'t continue until this is resolved.');
        }

        /** Attempts to parse the CIDRAM defaults file. */
        $this->YAML->process($this->readFile($this->Vault . 'defaults.yml'), $this->CIDRAM, 0, true);

        /** Kills the script if parsing the configuration defaults file fails. */
        if (empty($this->CIDRAM['Config Defaults'])) {
            header('Content-Type: text/plain');
            die('[CIDRAM] Configuration defaults file is corrupt! Can\'t continue until this is resolved.');
        }

        /** Configuration to be populated here. */
        $this->Configuration = [];

        /** Attempts to parse a custom-defined CIDRAM configuration file. */
        if ($ConfigurationPath !== '' && is_readable($ConfigurationPath)) {
            if ($this->YAML->process($this->readFile($ConfigurationPath), $this->Configuration)) {
                /** Declare the configuration file used. */
                $this->ConfigurationPath = $this->canonical($ConfigurationPath);
            }
        }

        /** If HTTP_HOST is populated, attempts to parse a "configuration overrides file" (specific to the requested domain). */
        if (
            $this->ConfigurationPath === '' &&
            !empty($_SERVER['HTTP_HOST']) &&
            ($this->CIDRAM['Domain'] = preg_replace(['/^www\./', '/:/'], ['', '_'], strtolower($_SERVER['HTTP_HOST']))) &&
            !preg_match('/[^.\da-z_-]/', $this->CIDRAM['Domain']) &&
            is_readable($this->Vault . $this->CIDRAM['Domain'] . '.config.yml')
        ) {
            if ($this->YAML->process($this->readFile($this->Vault . $this->CIDRAM['Domain'] . '.config.yml'), $this->Configuration)) {
                /** Declare the configuration file used. */
                $this->ConfigurationPath = $this->canonical($this->Vault . $this->CIDRAM['Domain'] . '.config.yml');
            }
        }

        /** Attempts to parse the standard CIDRAM configuration file. */
        if ($this->ConfigurationPath === '' && is_readable($this->Vault . 'config.yml')) {
            if ($this->YAML->process($this->readFile($this->Vault . 'config.yml'), $this->Configuration)) {
                /** Declare the configuration file used. */
                $this->ConfigurationPath = $this->canonical($this->Vault . 'config.yml');
            }
        }

        /** Load any other configured import data. */
        foreach ($this->supplementary($this->Configuration['components']['imports'] ?? '', $this->ImportsPath) as $Supplement) {
            $this->YAML->process($this->readFile($Supplement), $this->CIDRAM);
        }

        /** Perform fallbacks and autotyping for missing configuration directives. */
        $this->fallback($this->CIDRAM['Config Defaults'], $this->Configuration);

        /** Load the default event handlers. */
        require $this->EventsPath . 'default.php';

        /** Load any other configured event handlers. */
        if (!empty($this->Configuration['components']['events'])) {
            foreach (array_unique(explode("\n", $this->Configuration['components']['events'])) as $LoadThis) {
                if (strlen($LoadThis) > 0 && substr($LoadThis, -4) === '.php' && !$this->isReserved($LoadThis) && is_readable($this->EventsPath . $LoadThis)) {
                    require $this->EventsPath . $LoadThis;
                }
            }
            unset($LoadThis);
        }

        /** Fetch the IP address of the current request. */
        $this->ipAddr = (new \Maikuolan\Common\IPHeader($this->Configuration['general']['ipaddr']))->Resolution;

        /** Adjusted present time. */
        $this->Now = time() + ($this->Configuration['general']['time_offset'] * 60);

        /** Set timezone. */
        if (!empty($this->Configuration['general']['timezone']) && $this->Configuration['general']['timezone'] !== 'SYSTEM') {
            date_default_timezone_set($this->Configuration['general']['timezone']);
        }

        /**
         * Process the request query and query variables (if any exist); These may be
         * occasionally used by certain extended rulesets.
         */
        if (!empty($_SERVER['QUERY_STRING'])) {
            $this->CIDRAM['Query'] = $_SERVER['QUERY_STRING'];
            parse_str($_SERVER['QUERY_STRING'], $this->CIDRAM['QueryVars']);
        } else {
            $this->CIDRAM['Query'] = '';
            $this->CIDRAM['QueryVars'] = [];
        }

        /** Set default hashing algorithm. */
        $this->DefaultAlgo = (
            !empty($this->Configuration['general']['default_algo']) && defined($this->Configuration['general']['default_algo'])
        ) ? constant($this->Configuration['general']['default_algo']) : PASSWORD_DEFAULT;

        /** Instantiate the request class. */
        $this->Request = new \Maikuolan\Common\Request();
        $this->Request->DefaultTimeout = $this->Configuration['general']['default_timeout'];
        if ($this->Configuration['logging']['outbound_request_log'] !== '') {
            $this->Request->ObjLoggerFile = $this->buildPath($this->Vault . $this->Configuration['logging']['outbound_request_log']);
        }
        $ChannelsArrayData = [];
        $this->YAML->process($this->readFile($this->Vault . 'channels.yml'), $ChannelsArrayData);
        $this->Request->Channels = $ChannelsArrayData ?: [];
        unset($ChannelsArrayData);
        if (!isset($this->Request->Channels['Triggers'])) {
            $this->Request->Channels['Triggers'] = [];
        }
        $this->Request->Disabled = $this->Configuration['general']['disabled_channels'];
        $this->Request->Proxy = $this->Configuration['general']['request_proxy'];
        $this->Request->ProxyAuth = $this->Configuration['general']['request_proxyauth'];
        $this->Request->UserAgent = $this->ScriptUA;
        $this->Request->SendToOut = (defined('DEV_DEBUG_MODE') && DEV_DEBUG_MODE === true);

        /** CIDRAM favicon. */
        [$this->CIDRAM['favicon'], $this->CIDRAM['favicon_extension']] = $this->fetchFavicon($this->Configuration['template_data']['theme']);

        /** If the language directive is empty, default to English. */
        if (empty($this->Configuration['general']['lang'])) {
            $this->Configuration['general']['lang'] = 'en';
        }

        /** Load CIDRAM core L10N data. */
        $this->loadL10N($this->Vault . 'l10n' . DIRECTORY_SEPARATOR . 'core' . DIRECTORY_SEPARATOR);

        $this->NumberFormatter = new \Maikuolan\Common\NumberFormatter($this->Configuration['general']['numbers']);
        $this->Demojibakefier = new \Maikuolan\Common\Demojibakefier();
        $this->OperationHandler = new \Maikuolan\Common\Operation();

        /** Set optional custom path to the CIDRAM cache file. */
        $this->CachePath = $CachePath === '' ? $this->Vault . 'cache.dat' : $CachePath;
    }

    /**
     * Reads and returns the contents of files.
     *
     * @param string $File The path and the name of the file to read.
     * @param int $Err Populates on error; Reference; Optional.
     * @return string The file's content, or an empty string on failure.
     */
    public function readFile(string $File, int &$Err = 0): string
    {
        /** Guard. */
        if ($File === '' || !is_file($File) || !is_readable($File)) {
            $Err = 1;
            return '';
        }

        $Data = file_get_contents($File);
        if (is_string($Data)) {
            return $Data;
        }
        $Err = 2;
        return '';
    }

    /**
     * Replaces encapsulated substrings within a string using the values of the
     * corresponding elements within an array.
     *
     * @param array $Needles An array containing replacement values.
     * @param string $Haystack The string to work with.
     * @param bool $L10N Whether to parse L10N placeholders found in the haystack.
     * @return string The string with its encapsulated substrings replaced.
     */
    public function parseVars(array $Needles, string $Haystack = '', bool $L10N = false): string
    {
        if ($Haystack === '') {
            return '';
        }
        if ($L10N && preg_match_all('~\{([.,%_ ?!\dA-Za-z()-]+)\}~', $Haystack, $Matches)) {
            foreach (array_unique($Matches[1]) as $Key) {
                if (($Value = $this->L10N->getString($Key)) !== '') {
                    $Haystack = str_replace('{' . $Key . '}', $Value, $Haystack);
                }
            }
        }
        foreach ($Needles as $Key => $Value) {
            if (!is_array($Value) && $Value !== null) {
                $Haystack = str_replace('{' . $Key . '}', $Value, $Haystack);
            }
        }
        return $Haystack;
    }

    /**
     * Fetches instructions from the `ignore.dat` file.
     *
     * @return array An array listing the sections that CIDRAM should ignore.
     */
    public function fetchIgnores(): array
    {
        $IgnoreMe = [];
        $IgnoreFile = $this->readFile($this->Vault . 'ignore.dat');
        if (strpos($IgnoreFile, "\r")) {
            $IgnoreFile = (
                strpos($IgnoreFile, "\r\n") !== false
            ) ? str_replace("\r", '', $IgnoreFile) : str_replace("\r", "\n", $IgnoreFile);
        }
        $IgnoreFile = "\n" . $IgnoreFile . "\n";
        $PosB = -1;
        while (true) {
            $PosA = strpos($IgnoreFile, "\nIgnore ", ($PosB + 1));
            if ($PosA === false) {
                break;
            }
            $PosA += 8;
            if (!$PosB = strpos($IgnoreFile, "\n", $PosA)) {
                break;
            }
            $Tag = substr($IgnoreFile, $PosA, ($PosB - $PosA));
            if (strlen($Tag)) {
                $IgnoreMe[$Tag] = true;
            }
            $PosB--;
        }
        return $IgnoreMe;
    }

    /**
     * Gets tags from signature files.
     *
     * @param string $Haystack The haystack to search within for the target tag.
     * @param int $Offset The position to start searching from within the haystack.
     * @param string $Tag The tag we're trying to get.
     * @param string $DefTag The value to use when the target tag isn't found.
     * @return string The value of the tag we're trying to get, or of DefTag.
     */
    public function getter(string $Haystack, int $Offset, string $Tag, string $DefTag): string
    {
        $Key = "\n" . $Tag . ': ';
        $KeyLen = strlen($Key);
        return (
            ($PosX = strpos($Haystack, $Key, $Offset)) &&
            ($PosY = strpos($Haystack, "\n", $PosX + 1)) &&
            !substr_count($Haystack, "\n\n", $Offset, $PosX - $Offset + 1)
        ) ? substr($Haystack, $PosX + $KeyLen, $PosY - $PosX - $KeyLen) : $DefTag;
    }

    /**
     * Checks CIDRs (the factors expanded from an IP address) against the
     * signature files, and if any matches are found, performs the
     * actions specified by the given matches.
     *
     * @param array $Files Which IPv4/IPv6 signature files to check against.
     * @param array $Factors Which CIDRs/factors to check against.
     * @throws Exception if a triggered signature indicates a non-existent file to run.
     * @return bool Returns true.
     */
    public function checkFactors(array $Files, array $Factors): bool
    {
        $Counts = [
            'Files' => count($Files),
            'Factors' => count($Factors)
        ];
        if (!isset($this->CIDRAM['FileCache'])) {
            $this->CIDRAM['FileCache'] = [];
        }
        if (!isset($this->CIDRAM['FileCacheErrors'])) {
            $this->CIDRAM['FileCacheErrors'] = [];
        }
        for ($FileIndex = 0; $FileIndex < $Counts['Files']; $FileIndex++) {
            $Files[$FileIndex] = (
                strpos($Files[$FileIndex], ':') === false
            ) ? $Files[$FileIndex] : substr($Files[$FileIndex], strpos($Files[$FileIndex], ':') + 1);
            if ($Files[$FileIndex] === '' || $this->isReserved($Files[$FileIndex])) {
                continue;
            }
            if ($Counts['Factors'] === 32) {
                $DefTag = $Files[$FileIndex] . '-IPv4';
            } elseif ($Counts['Factors'] === 128) {
                $DefTag = $Files[$FileIndex] . '-IPv6';
            } else {
                $DefTag = $Files[$FileIndex] . '-Unknown';
            }
            if (!isset($this->CIDRAM['FileCache'][$Files[$FileIndex]])) {
                $this->CIDRAM['FileCacheErrors'][$Files[$FileIndex]] = 0;
                $this->CIDRAM['FileCache'][$Files[$FileIndex]] = $this->readFile($this->SignaturesPath . $Files[$FileIndex], $this->CIDRAM['FileCacheErrors'][$Files[$FileIndex]]);
            }
            if ($this->CIDRAM['FileCache'][$Files[$FileIndex]] === '') {
                if ($this->CIDRAM['FileCacheErrors'][$Files[$FileIndex]] !== 0 && $this->Configuration['signatures']['conflict_response'] !== 0) {
                    $this->conflictResponse('F' . $FileIndex);
                    if (isset($this->Stages['Tests:Tracking'])) {
                        $this->BlockInfo['Infractions']--;
                    }
                    break;
                }
                continue;
            }
            $FileExtension = strtolower(substr($Files[$FileIndex], -4));
            $Files[$FileIndex] = $this->CIDRAM['FileCache'][$Files[$FileIndex]];
            if (
                $FileExtension === '.csv' &&
                strpos($Files[$FileIndex], "\n") === false &&
                strpos($Files[$FileIndex], "\r") === false &&
                strpos($Files[$FileIndex], ",") !== false
            ) {
                $Files[$FileIndex] = ',' . $Files[$FileIndex] . ',';
                $SigFormat = 'CSV';
            } else {
                $SigFormat = 'DAT';
            }
            $Files[$FileIndex] = (
                strpos($Files[$FileIndex], "\r\n") !== false
            ) ? str_replace("\r", '', $Files[$FileIndex]) : str_replace("\r", "\n", $Files[$FileIndex]);
            $Files[$FileIndex] = "\n" . $Files[$FileIndex] . "\n";
            if ($Counts['Factors'] === 32) {
                if ($SigFormat === 'CSV') {
                    $NoCIDR = ',' . substr($Factors[31], 0, -3) . ',';
                    $LastCIDR = ',' . $Factors[31] . ',';
                } else {
                    $NoCIDR = "\n" . substr($Factors[31], 0, -3) . ' ';
                    $LastCIDR = "\n" . $Factors[31] . ' ';
                }
            } elseif ($Counts['Factors'] === 128) {
                if ($SigFormat === 'CSV') {
                    $NoCIDR = ',' . substr($Factors[127], 0, -4) . ',';
                    $LastCIDR = ',' . $Factors[127] . ',';
                } else {
                    $NoCIDR = "\n" . substr($Factors[127], 0, -4) . ' ';
                    $LastCIDR = "\n" . $Factors[127] . ' ';
                }
            }
            if (stripos($Files[$FileIndex], $NoCIDR) !== false) {
                $Files[$FileIndex] = str_ireplace($NoCIDR, $LastCIDR, $Files[$FileIndex]);
            }
            if ($SigFormat === 'CSV') {
                $LN = ' ("' . $DefTag . '", L0:F' . $FileIndex . ')';
                for ($FactorIndex = 0; $FactorIndex < $Counts['Factors']; $FactorIndex++) {
                    if ($Occurs = substr_count($Files[$FileIndex], ',' . $Factors[$FactorIndex] . ',')) {
                        $this->BlockInfo['ReasonMessage'] = $this->ClientL10N->getString('ReasonMessage.Generic') ?: $this->L10N->getString('ReasonMessage.Generic');
                        if (!empty($this->BlockInfo['WhyReason'])) {
                            $this->BlockInfo['WhyReason'] .= ', ';
                        }
                        $this->BlockInfo['WhyReason'] .= $this->L10N->getString('Short.Generic') . $LN;
                        if (!empty($this->BlockInfo['Signatures'])) {
                            $this->BlockInfo['Signatures'] .= ', ';
                        }
                        $this->BlockInfo['Signatures'] .= $Factors[$FactorIndex];
                        $this->BlockInfo['SignatureCount'] += $Occurs;
                    }
                }
                continue;
            }
            for ($FactorIndex = 0; $FactorIndex < $Counts['Factors']; $FactorIndex++) {
                $PosB = -1;
                while (true) {
                    $PosA = stripos($Files[$FileIndex], "\n" . $Factors[$FactorIndex] . ' ', ($PosB + 1));
                    if ($PosA === false) {
                        break;
                    }
                    $PosA += strlen($Factors[$FactorIndex]) + 2;
                    if (!$PosB = strpos($Files[$FileIndex], "\n", $PosA)) {
                        break;
                    }
                    if ($DefersTo = $this->getter($Files[$FileIndex], $PosA, 'Defers to', '')) {
                        $DefersTo = preg_quote($DefersTo);
                        if (
                            preg_match('~(?:^|\n)' . $DefersTo . '(?:$|\n)~i', $this->Configuration['components']['ipv4']) ||
                            preg_match('~(?:^|\n)' . $DefersTo . '(?:$|\n)~i', $this->Configuration['components']['ipv6'])
                        ) {
                            continue;
                        }
                    }
                    if ($Profile = $this->getter($Files[$FileIndex], $PosA, 'Profile', '')) {
                        $this->addProfileEntry($Profile);
                    }
                    $Tag = $this->getter($Files[$FileIndex], $PosA, 'Tag', $DefTag);
                    if (
                        ($Expires = $this->getter($Files[$FileIndex], $PosA, 'Expires', '')) &&
                        ($Expires = $this->fetchExpires($Expires)) &&
                        $Expires < $this->Now
                    ) {
                        $this->BlockInfo['Expired'] .= $this->BlockInfo['Expired'] ? ', ' . $Tag : $Tag;
                        continue;
                    }
                    if (!empty($this->CIDRAM['Ignore'][$Tag])) {
                        $this->BlockInfo['Ignored'] .= $this->BlockInfo['Ignored'] ? ', ' . $Tag : $Tag;
                        continue;
                    }
                    if ($Origin = $this->getter($Files[$FileIndex], $PosA, 'Origin', '')) {
                        if (!empty($this->CIDRAM['Ignore'][$Tag . ':' . $Origin])) {
                            $this->BlockInfo['Ignored'] .= $this->BlockInfo['Ignored'] ? ', ' . $Tag . ':' . $Origin : $Tag . ':' . $Origin;
                            continue;
                        }
                        $Origin = ', [' . $Origin . ']';
                    }
                    if (
                        ($PosX = strpos($Files[$FileIndex], "\n---\n", $PosA)) &&
                        ($PosY = strpos($Files[$FileIndex], "\n\n", ($PosX + 1))) &&
                        !substr_count($Files[$FileIndex], "\n\n", $PosA, ($PosX - $PosA + 1))
                    ) {
                        $this->YAML->process(substr($Files[$FileIndex], ($PosX + 5), ($PosY - $PosX - 5)), $this->Configuration);
                    }
                    $LN = ' ("' . $Tag . '", L' . substr_count($Files[$FileIndex], "\n", 0, $PosA) . ':F' . $FileIndex . $Origin . ')';
                    $Signature = substr($Files[$FileIndex], $PosA, ($PosB - $PosA));
                    $PosS = strpos($Signature, ' ');
                    $Category = $PosS === false ? '' : substr($Signature, 0, $PosS);
                    if ($Category === '') {
                        $Category = $Signature;
                        $Signature = '';
                    } else {
                        $Signature = substr($Signature, $PosS + 1);
                        if (preg_match('~ until (\d{4})[.-](\d\d)[.-](\d\d)$~i', $Signature, $EndParts)) {
                            $Until = mktime(0, 0, 0, (int)$EndParts[2], (int)$EndParts[3], (int)$EndParts[1]);
                            if ($this->Now > $Until) {
                                continue;
                            }
                            $Signature = preg_replace('~ until (\d{4})[.-](\d\d)[.-](\d\d)$~i', '', $Signature);
                        }
                        if (preg_match('~ from (\d{4})[.-](\d\d)[.-](\d\d)$~i', $Signature, $EndParts)) {
                            $From = mktime(0, 0, 0, (int)$EndParts[2], (int)$EndParts[3], (int)$EndParts[1]);
                            if ($this->Now < $From) {
                                continue;
                            }
                            $Signature = preg_replace('~ from (\d{4})[.-](\d\d)[.-](\d\d)$~i', '', $Signature);
                        }
                    }
                    $RunExitCode = 0;
                    if ($Category === 'Run' && $Signature !== '') {
                        if (!isset($this->CIDRAM['RunParamResCache'])) {
                            $this->CIDRAM['RunParamResCache'] = [];
                        }
                        if (isset($this->CIDRAM['RunParamResCache'][$Signature]) && is_object($this->CIDRAM['RunParamResCache'][$Signature])) {
                            $RunExitCode = $this->CIDRAM['RunParamResCache'][$Signature]($Factors, $FactorIndex, $LN, $Tag);
                        } elseif (!$this->isReserved($Signature) && is_readable($this->Vault . $Signature)) {
                            require_once $this->Vault . $Signature;
                        } else {
                            $this->CIDRAM['ExtraErrorInfo'] = $Signature;
                            trigger_error($this->L10N->getString('response.Required files are missing'), E_USER_WARNING);
                        }
                    }
                    if ($RunExitCode === 4) {
                        $this->zeroOutBlockInfo();
                        break 3;
                    }
                    if ($Category === 'Whitelist' || $RunExitCode === 3) {
                        $this->zeroOutBlockInfo(true);
                        break 3;
                    }
                    if ($Category === 'Greylist' || $RunExitCode === 2) {
                        $this->zeroOutBlockInfo();
                        break 2;
                    }
                    if (($Category === 'Deny' || $RunExitCode === 1) && $Signature !== '') {
                        if (
                            isset($this->CIDRAM['Config Defaults']['signatures']['shorthand']['choices'][$Signature]) &&
                            !isset($this->CIDRAM['Config Defaults']['signatures']['shorthand']['nonsense'][$Signature . ':Profile'])
                        ) {
                            if (isset($this->Shorthand[$Signature . ':Profile'])) {
                                $this->addProfileEntry($Signature);
                            }
                        } elseif (isset($this->Shorthand['Other:Profile'])) {
                            $this->addProfileEntry($Signature);
                        }
                        $DenyMatched = false;
                        if (
                            isset($this->CIDRAM['Config Defaults']['signatures']['shorthand']['choices'][$Signature]) &&
                            !isset($this->CIDRAM['Config Defaults']['signatures']['shorthand']['nonsense'][$Signature . ':Block'])
                        ) {
                            if (!isset($this->Shorthand[$Signature . ':Block'])) {
                                continue;
                            }
                            $this->BlockInfo['ReasonMessage'] = $this->ClientL10N->getString('ReasonMessage.' . $Signature) ?: $this->L10N->getString('ReasonMessage.' . $Signature) ?: $Signature;
                            if (!empty($this->BlockInfo['WhyReason'])) {
                                $this->BlockInfo['WhyReason'] .= ', ';
                            }
                            $this->BlockInfo['WhyReason'] .= ($this->L10N->getString('Short.' . $Signature) ?: $Signature) . $LN;
                            $DenyMatched = true;
                            if (isset($this->Shorthand[$Signature . ':Suppress'])) {
                                $this->CIDRAM['Suppress output template'] = true;
                            }
                        } else {
                            if (!isset($this->Shorthand['Other:Block'])) {
                                continue;
                            }
                            $this->BlockInfo['ReasonMessage'] = $Signature;
                            if (!empty($this->BlockInfo['WhyReason'])) {
                                $this->BlockInfo['WhyReason'] .= ', ';
                            }
                            $this->BlockInfo['WhyReason'] .= $Signature . $LN;
                            $DenyMatched = true;
                            if (isset($this->Shorthand['Other:Suppress'])) {
                                $this->CIDRAM['Suppress output template'] = true;
                            }
                        }
                        if (!$DenyMatched) {
                            continue;
                        }
                        if (!empty($this->BlockInfo['Signatures'])) {
                            $this->BlockInfo['Signatures'] .= ', ';
                        }
                        $this->BlockInfo['Signatures'] .= $Factors[$FactorIndex];
                        $this->BlockInfo['SignatureCount']++;
                    }
                }
            }
        }
        return true;
    }

    /**
     * Initialises all IPv4/IPv6 tests.
     *
     * @param string $Addr The IP address to check.
     * @param bool $Retain Specifies whether we need to retain factors for later.
     * @throws Exception if CheckFactors throws an exception.
     * @return bool Returns false if all tests fail, or true otherwise.
     */
    public function runTests(string $Addr, bool $Retain = false): bool
    {
        /** Guard. */
        if (!isset($this->BlockInfo)) {
            return false;
        }

        /** Fetch ignore.dat data. */
        if (!isset($this->CIDRAM['Ignore'])) {
            $this->CIDRAM['Ignore'] = $this->fetchIgnores();
        }

        $this->CIDRAM['Whitelisted'] = false;
        $this->CIDRAM['LastTestIP'] = 0;

        /** Test an IPv4 address. */
        if ($IPv4Factors = $this->expandIpv4($Addr)) {
            $IPv4Files = explode("\n", $this->Configuration['components']['ipv4']);
            try {
                $IPv4Test = $this->checkFactors($IPv4Files, $IPv4Factors);
            } catch (\Exception $e) {
                throw new \Exception($e->getMessage());
            }
            if ($IPv4Test) {
                $this->CIDRAM['LastTestIP'] = 4;
                if ($Retain) {
                    $this->CIDRAM['Factors'] = $IPv4Factors;
                }
            }
        } else {
            $IPv4Test = false;
        }

        /** Test an IPv6 address. */
        if ($IPv6Factors = $this->expandIpv6($Addr)) {
            $IPv6Files = explode("\n", $this->Configuration['components']['ipv6']);
            try {
                $IPv6Test = $this->checkFactors($IPv6Files, $IPv6Factors);
            } catch (\Exception $e) {
                throw new \Exception($e->getMessage());
            }
            if ($IPv6Test) {
                $this->CIDRAM['LastTestIP'] = 6;
                if ($Retain) {
                    $this->CIDRAM['Factors'] = $IPv6Factors;
                }
            }
        } else {
            $IPv6Test = false;
        }

        /** True when an IPv4 or IPv6 address has been successfully tested. False otherwise. */
        return ($IPv4Test || $IPv6Test);
    }

    /**
     * Zeros out blockinfo and optionally sets the whitelisted flag.
     *
     * @param bool $Whitelist Whether to set the whitelisted flag.
     * @return void
     */
    public function zeroOutBlockInfo(bool $Whitelist = false): void
    {
        $this->BlockInfo['Signatures'] = '';
        $this->BlockInfo['ReasonMessage'] = '';
        $this->BlockInfo['WhyReason'] = '';
        $this->BlockInfo['SignatureCount'] = 0;
        if ($Whitelist) {
            $this->CIDRAM['Whitelisted'] = true;
        }
    }

    /**
     * Reduces code duplicity (the contained code used by multiple parts of the
     * script for dealing with expiry tags).
     *
     * @param string $in Expiry tag.
     * @return int|bool A unix timestamp representing the expiry tag, or false if
     *      the expiry tag doesn't contain a valid ISO 8601 date/time.
     */
    public function fetchExpires(string $in)
    {
        static $CommonPart = '([12]\d{3})(?:\xe2\x88\x92|[\x2d-\x2f\x5c])?(0[1-9]|1[0-2])(?:\xe2\x88\x92|[\x2d-\x2f\x5c])?(0[1-9]|[1-2]\d|3[01])';
        if (
            preg_match('/^' . $CommonPart . '\x20?T?([01]\d|2[0-3])[\x2d\x2e\x3a]?([0-5]\d)[\x2d\x2e\x3a]?([0-5]\d)$/i', $in, $Arr) ||
            preg_match('/^' . $CommonPart . '\x20?T?([01]\d|2[0-3])[\x2d\x2e\x3a]?([0-5]\d)$/i', $in, $Arr) ||
            preg_match('/^' . $CommonPart . '\x20?T?([01]\d|2[0-3])$/i', $in, $Arr) ||
            preg_match('/^' . $CommonPart . '$/i', $in, $Arr) ||
            preg_match('/^([12]\d{3})(?:\xe2\x88\x92|[\x2d-\x2f\x5c])?(0[1-9]|1[0-2])$/i', $in, $Arr) ||
            preg_match('/^([12]\d{3})$/i', $in, $Arr)
        ) {
            $Arr = [
                (int)$Arr[1],
                isset($Arr[2]) ? (int)$Arr[2] : 1,
                isset($Arr[3]) ? (int)$Arr[3] : 1,
                isset($Arr[4]) ? (int)$Arr[4] : 0,
                isset($Arr[5]) ? (int)$Arr[5] : 0,
                isset($Arr[6]) ? (int)$Arr[6] : 0
            ];
            $Expires = mktime($Arr[3], $Arr[4], $Arr[5], $Arr[1], $Arr[2], $Arr[0]);
            return $Expires ?: false;
        }
        return false;
    }

    /**
     * A simple method for replacing date/time placeholders with corresponding
     * date/time information. Used by logging and some timestamps.
     *
     * @param int $Time A unix timestamp.
     * @param string|array $In An input or an array of inputs to manipulate.
     * @return string|array The adjusted input(/s).
     */
    public function timeFormat(int $Time, $In)
    {
        /** Guard. */
        if (!is_array($In) && (strpos($In, '{') === false || strpos($In, '}') === false)) {
            return $In;
        }

        $Time = date('dmYHisDMP', $Time);
        $Values = [
            'dd' => substr($Time, 0, 2),
            'mm' => substr($Time, 2, 2),
            'yyyy' => substr($Time, 4, 4),
            'yy' => substr($Time, 6, 2),
            'hh' => substr($Time, 8, 2),
            'ii' => substr($Time, 10, 2),
            'ss' => substr($Time, 12, 2),
            'Day' => substr($Time, 14, 3),
            'Mon' => substr($Time, 17, 3),
            'tz' => substr($Time, 20, 3) . substr($Time, 24, 2),
            't:z' => substr($Time, 20, 6)
        ];
        $Values['d'] = (int)$Values['dd'];
        $Values['m'] = (int)$Values['mm'];
        $Values['h'] = (int)$Values['hh'];
        $Values['i'] = (int)$Values['ii'];
        $Values['s'] = (int)$Values['ss'];
        return is_array($In) ? array_map(function (string $Item) use (&$Values): string {
            return $this->parseVars($Values, $Item);
        }, $In) : $this->parseVars($Values, $In);
    }

    /**
     * Fix incorrect typecasting for some for some variables that sometimes default
     * to strings instead of booleans or integers.
     *
     * @param mixed $Var The variable to fix (passed by reference).
     * @param string $Type The type (or pseudo-type) to cast the variable to.
     * @return void
     */
    public function autoType(&$Var, string $Type = ''): void
    {
        if (in_array($Type, ['checkbox', 'email', 'string', 'timezone', 'url'], true)) {
            $Var = (string)$Var;
        } elseif ($Type === 'float') {
            $Var = (float)$Var;
        } elseif ($Type === 'int') {
            $Var = (int)$Var;
        } elseif ($Type === 'duration') {
            $Var = new Duration($Var);
        } elseif ($Type === 'bool') {
            $Var = (strtolower($Var) !== 'false' && $Var);
        } elseif ($Type === 'kb') {
            $Var = $this->readBytes((string)$Var, 1);
        } else {
            $LVar = strtolower($Var);
            if ($LVar === 'true') {
                $Var = true;
            } elseif ($LVar === 'false') {
                $Var = false;
            } elseif ($Var !== true && $Var !== false) {
                $Var = (int)$Var;
            }
        }
    }

    /**
     * Performs fallbacks and autotyping for missing configuration directives.
     *
     * @param array $Fallbacks Fallback source.
     * @param array $Config Configuration source.
     * @return void
     */
    public function fallback(array $Fallbacks, array &$Config): void
    {
        foreach ($Fallbacks as $KeyCat => $DCat) {
            if (!is_array($DCat)) {
                continue;
            }
            if (!isset($Config[$KeyCat])) {
                $Config[$KeyCat] = [];
            }
            if (isset($Cat)) {
                unset($Cat);
            }
            $Cat = &$Config[$KeyCat];
            foreach ($DCat as $DKey => $DData) {
                if (isset($DData['labels'], $DData['style']) && $DData['style'] === 'matrix') {
                    if (!isset($Cat[$DKey]) || !is_array($Cat[$DKey])) {
                        $Cat[$DKey] = [];
                    }
                    foreach ($DData['labels'] as $MLabelKey => $MLabelValue) {
                        if (!isset($Cat[$DKey][$MLabelKey])) {
                            $Cat[$DKey][$MLabelKey] = $DData['default'][$MLabelKey] ?? '';
                        }
                        if (isset($Dir)) {
                            unset($Dir);
                        }
                        $Dir = &$Cat[$DKey][$MLabelKey];
                        $Type = $DData['type'][$MLabelKey] ?? $DData['type'] ?? '';
                        if (is_string($Type) && $Type !== '') {
                            $this->autoType($Dir, $Type);
                        }
                    }
                    continue;
                }
                if (!isset($Cat[$DKey])) {
                    $Cat[$DKey] = $DData['default'] ?? '';
                }
                if (isset($Dir)) {
                    unset($Dir);
                }
                $Dir = &$Cat[$DKey];
                if (isset($DData['value_preg_filter']) && is_array($DData['value_preg_filter'])) {
                    foreach ($DData['value_preg_filter'] as $FilterKey => $FilterValue) {
                        $Dir = preg_replace($FilterKey, $FilterValue, $Dir);
                    }
                }
                if (isset($DData['type'])) {
                    $this->autoType($Dir, $DData['type']);
                }
            }
        }
    }

    /**
     * Check for supplementary configuration.
     *
     * @param string $Source The directive or CSV that we're checking from.
     * @param string $Path Where configuration files might potentially be found.
     * @return array An array of valid supplementary configuration sources.
     */
    public function supplementary(string $Source, string $Path = ''): array
    {
        $Out = [];
        $Source = explode("\n", $Source);
        foreach ($Source as $File) {
            if (is_readable($Path . $File)) {
                $Out[] = $Path . $File;
            }
        }
        return array_unique($Out);
    }

    /**
     * Performs reverse DNS lookups for IP addresses, to resolve their hostnames.
     * This is functionally equivalent to the in-built "gethostbyaddr" PHP
     * function, but with the added benefit of being able to specify which DNS
     * servers to use for lookups, and of being able to enforce timeouts, which
     * should help to avoid some of the problems normally encountered by using
     * "gethostbyaddr".
     *
     * @param string $Addr The IP address to look up.
     * @param string $DNS An optional, comma delimited list of DNS servers to use.
     * @param int $Timeout The timeout limit (optional; defaults to 5 seconds).
     * @return string The hostname on success, or the IP address on failure.
     */
    public function dnsReverse(string $Addr, string $DNS = '', int $Timeout = 5): string
    {
        /** Shouldn't try to reverse localhost addresses; There'll be problems. */
        if ($Addr === '127.0.0.1' || $Addr === '::1') {
            return 'localhost';
        }

        /** We've already got it cached. We can return the results early. */
        if (isset($this->CIDRAM['DnsReverses-' . $Addr], $this->CIDRAM['DnsReverses-' . $Addr]['Host'])) {
            return $this->CIDRAM['DnsReverses-' . $Addr]['Host'];
        }
        if (($Try = $this->Cache->getEntry('DnsReverses-' . $Addr)) !== false && is_array($Try) && isset($Try['Host'])) {
            $this->CIDRAM['DnsReverses-' . $Addr] = $Try;
            return $Try['Host'];
        }

        if (strpos($Addr, '.') !== false && strpos($Addr, ':') === false && preg_match(
            '/^([01]?\d{1,2}|2[0-4]\d|25[0-5])\.([01]?\d{1,2}|2[0-4]\d|25[0-5])' .
            '\.([01]?\d{1,2}|2[0-4]\d|25[0-5])\.([01]?\d{1,2}|2[0-4]\d|25[0-5])$/',
            $Addr,
            $Octets
        )) {
            /** The IP address is IPv4. */
            $Lookup =
                chr(strlen($Octets[4])) . $Octets[4] .
                chr(strlen($Octets[3])) . $Octets[3] .
                chr(strlen($Octets[2])) . $Octets[2] .
                chr(strlen($Octets[1])) . $Octets[1] .
                "\7in-addr\4arpa\0\0\x0c\0\1";
        } elseif (strpos($Addr, '.') === false && strpos($Addr, ':') !== false && $this->expandIpv6($Addr, true)) {
            /** The IP address is IPv6. */
            $Lookup = $Addr;
            if (strpos($Addr, '::') !== false) {
                $Repeat = 8 - substr_count($Addr, ':');
                $Lookup = str_replace('::', str_repeat(':0', ($Repeat < 1 ? 0 : $Repeat)) . ':', $Lookup);
            }
            while (strlen($Lookup) < 39) {
                $Lookup = preg_replace(
                    ['/^:/', '/:$/', '/^([\da-f]{1,3}):/i', '/:([\da-f]{1,3})$/i', '/:([\da-f]{1,3}):/i'],
                    ['0:', ':0', '0\1:', ':0\1', ':0\1:'],
                    $Lookup
                );
            }
            $Lookup = strrev(preg_replace(['/:/', '/(.)/'], ['', "\\1\1"], $Lookup)) . "\3ip6\4arpa\0\0\x0c\0\1";
        } else {
            /** The IP address is.. wrong. Let's exit the method. */
            return $Addr;
        }

        /** Sending UDP is usually pointless if we're not on root. */
        if (!isset($this->CIDRAM['Root'])) {
            $this->CIDRAM['Root'] = (!function_exists('posix_getuid') || posix_getuid() === 0);
        }

        /** Use gethostbyaddr if enabled and if we anticipate UDP failing. */
        if (!$this->CIDRAM['Root'] && $this->Configuration['general']['allow_gethostbyaddr_lookup']) {
            return $this->dnsReverseFallback($Addr);
        }

        /** Some safety mechanisms. */
        if (!isset($this->CIDRAM['_allow_url_fopen'])) {
            $this->CIDRAM['_allow_url_fopen'] = ini_get('allow_url_fopen');
            $this->CIDRAM['_allow_url_fopen'] = !(!$this->CIDRAM['_allow_url_fopen'] || $this->CIDRAM['_allow_url_fopen'] === 'Off');
        }
        if (!$this->CIDRAM['Root'] || empty($Lookup) || !function_exists('fsockopen') || !$this->CIDRAM['_allow_url_fopen']) {
            return $Addr;
        }

        /** DNS is disabled. Let's exit the method. */
        if (strlen($DNS) === 0 && strlen($this->Configuration['general']['default_dns']) === 0) {
            return $this->Configuration['general']['allow_gethostbyaddr_lookup'] ? $this->dnsReverseFallback($Addr) : $Addr;
        }

        /** Expand list of lookup servers. */
        $DNS = explode("\n", $this->Configuration['general']['default_dns']);

        /** UDP padding. */
        $LeftPad = str_pad(rand(0, 99), 2, '0', STR_PAD_LEFT) . "\1\0\0\1\0\0\0\0\0\0";

        /** Perform the lookup. */
        foreach ($DNS as $Server) {
            if (!empty($Response) || !$Server) {
                break;
            }
            $Handle = fsockopen('udp://' . $Server, 53);
            if ($Handle !== false) {
                fwrite($Handle, $LeftPad . $Lookup);
                stream_set_timeout($Handle, $Timeout);
                stream_set_blocking($Handle, true);
                $Response = fread($Handle, 1024);
                fclose($Handle);
            }
        }

        /** No response, or failed lookup. Let's exit the method. */
        if (empty($Response)) {
            if ($this->Configuration['general']['allow_gethostbyaddr_lookup']) {
                return $this->dnsReverseFallback($Addr);
            } else {
                $this->CIDRAM['DnsReverses-' . $Addr] = ['Host' => $Addr];
                $this->Cache->setEntry('DnsReverses-' . $Addr, $this->CIDRAM['DnsReverses-' . $Addr], 21600);
                return $this->CIDRAM['DnsReverses-' . $Addr]['Host'];
            }
        }

        /** We got a response! Now let's process it accordingly. */
        $Host = '';
        if (($Pos = strpos($Response, $Lookup)) !== false) {
            $Pos += strlen($Lookup) + 12;
            while (($Byte = substr($Response, $Pos, 1)) && $Byte !== "\0") {
                if ($Host) {
                    $Host .= '.';
                }
                $Len = hexdec(bin2hex($Byte));
                $Host .= substr($Response, $Pos + 1, $Len);
                $Pos += 1 + $Len;
            }
        }
        $this->CIDRAM['DnsReverses-' . $Addr] = ['Host' => preg_replace('/[^:\da-z._~-]/i', '', $Host) ?: $Addr];
        $this->Cache->setEntry('DnsReverses-' . $Addr, $this->CIDRAM['DnsReverses-' . $Addr], 21600);
        return $this->CIDRAM['DnsReverses-' . $Addr]['Host'];
    }

    /**
     * Fallback for failed lookups.
     *
     * @param string $Addr The IP address to look up.
     * @return string The results of gethostbyaddr(), or the IP address verbatim.
     */
    public function dnsReverseFallback(string $Addr): string
    {
        $this->CIDRAM['DnsReverses-' . $Addr] = ['Host' => preg_replace('/[^:\da-z._~-]/i', '', gethostbyaddr($Addr)) ?: $Addr];
        $this->Cache->setEntry('DnsReverses-' . $Addr, $this->CIDRAM['DnsReverses-' . $Addr], 21600);
        return $this->CIDRAM['DnsReverses-' . $Addr]['Host'];
    }

    /**
     * Performs forward DNS lookups for hostnames, to resolve their IP address.
     * This is functionally equivalent to the in-built PHP function
     * "gethostbyname", but with the added benefits of having IPv6 support and of
     * being able to enforce timeout limits, which should help to avoid some of the
     * problems normally associated with using "gethostbyname").
     *
     * @param string $Host The hostname to look up.
     * @param int $Timeout The timeout limit (optional; defaults to 5 seconds).
     * @return string The IP address on success, or an empty string on failure.
     */
    public function dnsResolve(string $Host, int $Timeout = 5): string
    {
        if (isset($this->CIDRAM['DnsForwards-' . $Host])) {
            return $this->CIDRAM['DnsForwards-' . $Host];
        }
        if (($Try = $this->Cache->getEntry('DnsForwards-' . $Host)) !== false) {
            return $this->CIDRAM['DnsForwards-' . $Host] = $Try;
        }
        $Host = urlencode($Host);
        if (($HostLen = strlen($Host)) > 253) {
            return '';
        }
        $RecordParam = '';
        $PadBase = 204;
        $TTL = 21600;
        if (isset($this->CIDRAM['LastTestIP'])) {
            if ($this->CIDRAM['LastTestIP'] === 4) {
                $RecordParam = '&type=A';
                $PadBase = 211;
            } elseif ($this->CIDRAM['LastTestIP'] === 6) {
                $RecordParam = '&type=AAAA';
                $PadBase = 214;
                $TTL = 129600;
            }
        }

        $URI = 'https://dns.google.com/resolve?name=' . $Host . $RecordParam . '&random_padding=';
        $PadLen = $PadBase - $HostLen;
        if ($PadLen < 1) {
            $PadLen += 768;
        }
        while ($PadLen > 0) {
            $PadLen--;
            $URI .= str_shuffle(self::PAD_FOR_DNS)[0];
        }
        $Results = json_decode($this->Request->request($URI, [], $Timeout), true);
        if (empty($Results) || empty($Results['Answer'][0]['data'])) {
            $this->CIDRAM['DnsForwards-' . $Host] = '';
        } else {
            $this->CIDRAM['DnsForwards-' . $Host] = preg_replace('/[^\da-f.:]/i', '', $Results['Answer'][0]['data']);
        }
        $this->Cache->setEntry('DnsForwards-' . $Host, $this->CIDRAM['DnsForwards-' . $Host], $TTL);
        return $this->CIDRAM['DnsForwards-' . $Host];
    }

    /**
     * Used to identify bots ghosting/masquerading as search engines, crawlers,
     * social media tools, etc.
     *
     * @param string|array $Domains Accepted domain/hostname partials.
     * @param string $Friendly A friendly name to use when logging.
     * @param array $Values Verification data for the entity being verified.
     * @return void
     */
    public function dnsReverseForward($Domains, string $Friendly, array $Values): void
    {
        /** Fetch the hostname. */
        if (empty($this->CIDRAM['Hostname'])) {
            $this->CIDRAM['Hostname'] = $this->dnsReverse($this->BlockInfo['IPAddrResolved'] ?: $this->BlockInfo['IPAddr']);
        }

        /** Resolve the DNS hostname. */
        if (strlen($this->CIDRAM['Hostname']) === 0 || $this->CIDRAM['Hostname'] === $this->BlockInfo['IPAddr']) {
            /** Block non-verified requests. */
            if (isset($this->CIDRAM['VPermissions'][$Friendly . ':BlockNonVerified'])) {
                $this->trigger(true, sprintf($this->L10N->getString('Short.Unverified_UA'), $Friendly));
                $this->addProfileEntry('Blocked Non-Verified');
            }

            return;
        }

        /** Flag for whether our checks pass or fail. */
        $Pass = false;

        /** Force domains to be an array. */
        $this->arrayify($Domains);

        /** Compare the hostname against the accepted domain/hostname partials. */
        foreach ($Domains as $Domain) {
            $Len = strlen($Domain) * -1;
            if (
                substr($this->CIDRAM['Hostname'], $Len) === $Domain ||
                (substr($Domain, 0, 1) === '.' && $this->CIDRAM['Hostname'] === substr($Domain, 1))
            ) {
                $Pass = true;
                break;
            }
        }

        /** Successfully passed. */
        if ($Pass) {
            /** We're only reversing; Don't resolve. */
            if (!empty($Values['Reverse only'])) {
                /** Untrack positives. */
                if (isset($this->CIDRAM['VPermissions'][$Friendly . ':UntrackPositives'])) {
                    $this->CIDRAM['Trackable'] = false;
                }

                /** Populate "verified" field. */
                if (isset($this->BlockInfo['Verified'])) {
                    $this->BlockInfo['Verified'] = $Friendly;
                    $this->addProfileEntry('Verified');
                }

                /** Single-hit bypass. */
                $this->bypass((
                    isset($this->CIDRAM['VPermissions'][$Friendly . ':SingleHitBypass'], $this->BlockInfo['SignatureCount'], $this->BlockInfo['WhyReason']) &&
                    $this->BlockInfo['SignatureCount'] === 1 &&
                    preg_match('~, L\d+:F\d+,| Lookup~', $this->BlockInfo['WhyReason'])
                ), $this->L10N->getString('why_single_hit_bypass'));

                /** Exit. */
                return;
            }

            /** Attempt to resolve. */
            $Resolved = $this->dnsResolve($this->CIDRAM['Hostname']);

            /** Failed to resolve. */
            if ($Resolved === '') {
                /** Block non-verified requests. */
                if (isset($this->CIDRAM['VPermissions'][$Friendly . ':BlockNonVerified'])) {
                    $this->trigger(true, sprintf($this->L10N->getString('Short.Unverified_UA'), $Friendly));
                    $this->addProfileEntry('Blocked Non-Verified');
                }

                return;
            }

            /** It's the real deal. */
            if ($Resolved === $this->BlockInfo['IPAddr']) {
                /** Untrack positives. */
                if (isset($this->CIDRAM['VPermissions'][$Friendly . ':UntrackPositives'])) {
                    $this->CIDRAM['Trackable'] = false;
                }

                /** Populate "verified" field. */
                if (isset($this->BlockInfo['Verified'])) {
                    $this->BlockInfo['Verified'] = $Friendly;
                    $this->addProfileEntry('Verified');
                }

                /** Single-hit bypass. */
                $this->bypass((
                    isset($this->CIDRAM['VPermissions'][$Friendly . ':SingleHitBypass'], $this->BlockInfo['SignatureCount'], $this->BlockInfo['WhyReason']) &&
                    $this->BlockInfo['SignatureCount'] === 1 &&
                    preg_match('~, L\d+:F\d+,| Lookup~', $this->BlockInfo['WhyReason'])
                ), $this->L10N->getString('why_single_hit_bypass'));

                /** Exit. */
                return;
            }
        }

        /** It's a fake; Block it. */
        if (isset($this->CIDRAM['VPermissions'][$Friendly . ':BlockNegatives'])) {
            $this->trigger(true, sprintf($this->L10N->getString('Short.Fake_UA'), $Friendly));
            $this->addProfileEntry('Blocked Negative');

            /** Reporting. */
            $this->Reporter->report([19], ['Caught masquerading as ' . $Friendly . '.'], $this->BlockInfo['IPAddr']);
        }
    }

    /**
     * Checks whether an IP is expected. If so, tracking is disabled for the IP
     * being checked, and if not, the request is blocked. Has no return value.
     *
     * @param string|array $Expected Accepted/Expected IPs.
     * @param string $Friendly A friendly name to use when logging.
     * @param array $Values Verification data for the entity being verified.
     * @return void
     */
    public function uaIpMatch($Expected, string $Friendly, array $Values): void
    {
        /** Guard. */
        if (empty($this->BlockInfo['IPAddr'])) {
            return;
        }

        /** Route to matching code. */
        $this->uaXMatch($this->BlockInfo['IPAddr'], $Expected, $Friendly);
    }

    /**
     * Checks whether a CIDR is expected. If so, tracking is disabled for the IP
     * being checked, and if not, the request is blocked. Has no return value.
     *
     * @param string|array $Expected Accepted/Expected CIDRs.
     * @param string $Friendly A friendly name to use when logging.
     * @param array $Values Verification data for the entity being verified.
     * @return void
     */
    public function uaCidrMatch($Expected, string $Friendly, array $Values): void
    {
        /** Guard. */
        if (empty($this->CIDRAM['Factors'])) {
            return;
        }

        /** Route to matching code. */
        $this->uaXMatch($this->CIDRAM['Factors'], $Expected, $Friendly);
    }

    /**
     * Checks whether an ASN is expected. If so, tracking is disabled for the IP
     * of the request, and if not, the request is blocked. Has no return value.
     * Will only work as expected if a module or other facility of some kind,
     * capable of performing ASN lookups, has been enabled.
     *
     * @param string|array $Origins Accepted originating ASNs.
     * @param string $Friendly A friendly name to use when logging.
     * @param array $Values Verification data for the entity being verified.
     * @return void
     */
    public function uaAsnMatch($Origins, string $Friendly, array $Values): void
    {
        /** Guard. */
        if (empty($this->BlockInfo['ASNLookup']) || $this->BlockInfo['ASNLookup'] < 1) {
            return;
        }

        /** Route to matching code. */
        $this->uaXMatch($this->BlockInfo['ASNLookup'], $Origins, $Friendly);
    }

    /**
     * The main method for triggering signatures.
     *
     * @param bool $Condition Truthiness is evaluated. If true, the signature is
     *      "triggered". If false, the signature is *not* "triggered".
     * @param string $ReasonShort Cited in the "Why blocked" field when the
     *      signature is triggered and thus included within log entries.
     * @param string $ReasonLong Message displayed to the user/client when blocked,
     *      to explain why they've been blocked. Optional. Defaults to the standard
     *      "Access denied!" message.
     * @param array $DefineOptions An optional array containing key/value pairs,
     *      used to define configuration options specific to the request instance.
     *      Configuration options will be applied when the signature is triggered.
     * @return bool Returns true if the signature was triggered, and false if it
     *      wasn't. Should correspond to the truthiness of $Condition.
     */
    public function trigger(bool $Condition, string $ReasonShort, string $ReasonLong = '', array $DefineOptions = []): bool
    {
        if (!$Condition) {
            return false;
        }
        if ($ReasonLong === '') {
            $ReasonLong = $this->ClientL10N->getString('ReasonMessage.' . $ReasonShort) ?: $this->L10N->getString('ReasonMessage.' . $ReasonShort) ?: $this->ClientL10N->getString('denied') ?: $this->L10N->getString('denied');
        }
        if (count($DefineOptions) > 0) {
            foreach ($DefineOptions as $CatKey => $CatValue) {
                if (!is_array($CatValue)) {
                    continue;
                }
                foreach ($CatValue as $OptionKey => $OptionValue) {
                    $this->Configuration[$CatKey][$OptionKey] = $OptionValue;
                }
            }
        }
        $this->BlockInfo['ReasonMessage'] = $ReasonLong;
        if (!empty($this->BlockInfo['WhyReason'])) {
            $this->BlockInfo['WhyReason'] .= ', ';
        }
        $this->BlockInfo['WhyReason'] .= $ReasonShort;
        if (!empty($this->BlockInfo['Signatures'])) {
            $this->BlockInfo['Signatures'] .= ', ';
        }
        if ($this->Stage === 'Aux') {
            $this->BlockInfo['Signatures'] .= 'auxiliary.yml:' . $ReasonShort;
        } else {
            $Debug = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0];
            $Source = basename($Debug['file']);
            $this->BlockInfo['Signatures'] .= $Source . ':L' . $Debug['line'];
        }
        $this->BlockInfo['SignatureCount']++;
        if ($this->Events->assigned('triggered')) {
            $this->Events->fireEvent('triggered', $Source);
        }
        return true;
    }

    /**
     * The main method for triggering signature bypasses.
     *
     * @param bool $Condition Truthiness is evaluated. If true, the bypass is
     *      "triggered". If false, the bypass is *not* "triggered".
     * @param string $ReasonShort Cited in the "Why blocked" field when the
     *      bypass is triggered (included within log entries if there are still
     *      other preexisting signatures which have otherwise been triggered).
     * @param array $DefineOptions An optional array containing key/value
     *      pairs, used to define configuration options specific to the request
     *      instance. Configuration options will be applied when the bypass is
     *      triggered.
     * @return bool Returns true if the bypass was triggered, and false if it
     *      wasn't. Should correspond to the truthiness of $Condition.
     */
    public function bypass(bool $Condition, string $ReasonShort, array $DefineOptions = []): bool
    {
        if (!$Condition) {
            return false;
        }
        if (!empty($DefineOptions)) {
            foreach ($DefineOptions as $CatKey => $CatValue) {
                if (!is_array($CatValue)) {
                    continue;
                }
                foreach ($CatValue as $OptionKey => $OptionValue) {
                    $this->Configuration[$CatKey][$OptionKey] = $OptionValue;
                }
            }
        }
        if (!empty($this->BlockInfo['WhyReason'])) {
            $this->BlockInfo['WhyReason'] .= ', ';
        }
        $this->BlockInfo['WhyReason'] .= $ReasonShort;
        if (!empty($this->BlockInfo['Signatures'])) {
            $this->BlockInfo['Signatures'] .= ', ';
        }
        if ($this->Stage === 'Aux') {
            $this->BlockInfo['Signatures'] .= 'auxiliary.yml:' . $ReasonShort;
        } else {
            $Debug = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0];
            $Source = basename($Debug['file']);
            $this->BlockInfo['Signatures'] .= $Source . ':L' . $Debug['line'];
        }
        $this->BlockInfo['SignatureCount']--;
        if ($this->Events->assigned('bypassed')) {
            $this->Events->fireEvent('bypassed', $Source);
        }
        return true;
    }

    /**
     * Enact some options (used by some modules).
     *
     * @param string $Prefix The prefix for the options (in case of working with labels).
     * @param array $Options The options to enact.
     * @return void
     */
    public function enactOptions(string $Prefix = '', array $Options = []): void
    {
        if (isset($Options[$Prefix . 'MarkForUseWithReCAPTCHA'])) {
            $this->Configuration['recaptcha']['enabled'] = true;
        }
        if (isset($Options[$Prefix . 'ForciblyDisableReCAPTCHA'])) {
            $this->Configuration['recaptcha']['usemode'] = 0;
            $this->Configuration['recaptcha']['forcibly_disabled'] = true;
        }
        if (isset($Options[$Prefix . 'MarkForUseWithHCaptcha'])) {
            $this->Configuration['hcaptcha']['enabled'] = true;
        }
        if (isset($Options[$Prefix . 'ForciblyDisableHCaptcha'])) {
            $this->Configuration['hcaptcha']['usemode'] = 0;
            $this->Configuration['hcaptcha']['forcibly_disabled'] = true;
        }
    }

    /**
     * Used to generate new salts when necessary, which may be occasionally used by
     * some specific optional peripheral features (note: should not be considered
     * cryptographically secure; especially so for versions of PHP < 7).
     *
     * @return string Salt.
     */
    public function generateSalt(): string
    {
        $Salt = '';
        try {
            $Length = random_int(self::GENERATE_SALT_MIN_LEN, self::GENERATE_SALT_MAX_LEN);
        } catch (\Exception $e) {
            $Length = rand(self::GENERATE_SALT_MIN_LEN, self::GENERATE_SALT_MAX_LEN);
        }
        try {
            $Salt = random_bytes($Length);
        } catch (\Exception $e) {
            $Salt = '';
        }
        if (!strlen($Salt)) {
            try {
                for ($Index = 0; $Index < $Length; $Index++) {
                    $Salt .= chr(random_int(self::GENERATE_SALT_MIN_CHR, self::GENERATE_SALT_MAX_CHR));
                }
            } catch (\Exception $e) {
                $Salt = '';
                for ($Index = 0; $Index < $Length; $Index++) {
                    $Salt .= chr(rand(self::GENERATE_SALT_MIN_CHR, self::GENERATE_SALT_MAX_CHR));
                }
            }
        }
        return $Salt;
    }

    /**
     * If input isn't an array, make it so. Remove empty elements.
     *
     * @param mixed $Input
     */
    public function arrayify(&$Input)
    {
        if (!is_array($Input)) {
            $Input = [$Input];
        }
        $Input = array_filter($Input);
    }

    /**
     * Read byte value configuration directives as byte values.
     *
     * @param string $In Input.
     * @param int $Mode Operating mode. 0 for true byte values, 1 for validating.
     *      Default is 0.
     * @return string|int Output (return type depends on operating mode).
     */
    public function readBytes(string $In, int $Mode = 0)
    {
        $Unit = '';
        if (preg_match('/([KkMmGgTtPpOoBb]|К|к|М|м|Г|г|Т|т|П|п|Ｋ|ｋ|Ｍ|ｍ|Ｇ|ｇ|Ｔ|ｔ|Ｐ|ｐ|Б|б|Ｂ|ｂ)([OoBb]|Б|б|Ｂ|ｂ)?$/', $In, $Matches)) {
            if (preg_match('/^([Kk]|К|к)$/', $Matches[1])) {
                $Unit = 'K';
            } elseif (preg_match('/^([Mm]|М|м)$/', $Matches[1])) {
                $Unit = 'M';
            } elseif (preg_match('/^([Gg]|Г|г)$/', $Matches[1])) {
                $Unit = 'G';
            } elseif (preg_match('/^([Tt]|Т|т)$/', $Matches[1])) {
                $Unit = 'T';
            } elseif (preg_match('/^([Pp]|П|п)$/', $Matches[1])) {
                $Unit = 'P';
            }
        }
        $In = (float)$In;
        if ($Mode === 1) {
            return $Unit === '' ? $In . 'B' : $In . $Unit . 'B';
        }
        $Multiply = ['K' => 1024, 'M' => 1048576, 'G' => 1073741824, 'T' => 1099511627776, 'P' => 1125899906842620];
        if (isset($Multiply[$Unit])) {
            $In *= $Multiply[$Unit];
        }
        return (int)floor($In);
    }

    /**
     * Resolves 6to4, Teredo, ISATAP addresses and etc to their IPv4 counterparts.
     *
     * @param string $In An IPv6 address.
     * @return string An IPv4 address, or an empty string upon failure to resolve.
     */
    public function resolve6to4(string $In): string
    {
        if (!preg_match('~^(?:200[12]|fe80):~i', $In)) {
            return '';
        }
        $Parts = explode(':', $In, 8);

        /** 6to4. */
        if ($Parts[0] === '2002') {
            if (empty($Parts[1]) || empty($Parts[2]) || preg_match('~[^\da-f]~i', $Parts[1]) || preg_match('~[^\da-f]~i', $Parts[2])) {
                return '';
            }
            $Parts[1] = hexdec($Parts[1]) ?: 0;
            $Parts[2] = hexdec($Parts[2]) ?: 0;
            $Octets = [0 => floor($Parts[1] / 256), 1 => $Parts[1] % 256, 2 => floor($Parts[2] / 256), 3 => $Parts[2] % 256];
            return implode('.', $Octets);
        }

        /** Teredo. */
        if ($Parts[0] === '2001' && empty($Parts[1])) {
            $Parts = array_reverse($Parts);
            $Bits = ($Parts[1] ?: '') . str_pad(($Parts[0] ?: ''), 4, '0', STR_PAD_LEFT);
            if (preg_match('~[^\da-f]~i', $Bits)) {
                return '';
            }
            $Bits = hexdec($Bits) ^ 0xffffffff;
            $Octets = [0 => 0, 1 => 0, 2 => 0, 3 => $Bits % 256];
            $Octets[2] = ($Bits = floor($Bits / 256)) % 256;
            $Octets[1] = ($Bits = floor($Bits / 256)) % 256;
            $Octets[0] = floor($Bits / 256);
            return implode('.', $Octets);
        }

        /** ISATAP. */
        if (preg_match('~^fe80::(?:0200:)?5efe:([\da-f]{1,4}):([\da-f]{1,4})$~i', $In, $Parts)) {
            $Parts[1] = hexdec($Parts[1]) ?: 0;
            $Parts[2] = hexdec($Parts[2]) ?: 0;
            $Octets = [0 => floor($Parts[1] / 256), 1 => $Parts[1] % 256, 2 => floor($Parts[2] / 256), 3 => $Parts[2] % 256];
            return implode('.', $Octets);
        }

        return '';
    }

    /**
     * Initialise the cache.
     *
     * @return void
     */
    public function initialiseCache(): void
    {
        /** Guard. */
        if ($this->Cache instanceof \Maikuolan\Common\Cache) {
            return;
        }

        /** Create new cache object. */
        $this->Cache = new \Maikuolan\Common\Cache();
        $this->Cache->Prefix = $this->Configuration['supplementary_cache_options']['prefix'];
        $this->Cache->EnableAPCu = $this->Configuration['supplementary_cache_options']['enable_apcu'];
        $this->Cache->EnableMemcached = $this->Configuration['supplementary_cache_options']['enable_memcached'];
        $this->Cache->EnableRedis = $this->Configuration['supplementary_cache_options']['enable_redis'];
        $this->Cache->EnablePDO = $this->Configuration['supplementary_cache_options']['enable_pdo'];
        $this->Cache->MemcachedHost = $this->Configuration['supplementary_cache_options']['memcached_host'];
        $this->Cache->MemcachedPort = $this->Configuration['supplementary_cache_options']['memcached_port'];
        $this->Cache->RedisHost = $this->Configuration['supplementary_cache_options']['redis_host'];
        $this->Cache->RedisPort = $this->Configuration['supplementary_cache_options']['redis_port'];
        $this->Cache->RedisTimeout = $this->Configuration['supplementary_cache_options']['redis_timeout'];
        $this->Cache->RedisDatabaseNumber = $this->Configuration['supplementary_cache_options']['redis_database_number'];
        $this->Cache->PDOdsn = $this->Configuration['supplementary_cache_options']['pdo_dsn'];
        $this->Cache->PDOusername = $this->Configuration['supplementary_cache_options']['pdo_username'];
        $this->Cache->PDOpassword = $this->Configuration['supplementary_cache_options']['pdo_password'];
        $this->Cache->FFDefault = $this->CachePath;

        if (!$this->Cache->connect()) {
            $this->Events->fireEvent('final');
            if ($this->Cache->Using === 'FF') {
                header('Content-Type: text/plain');
                die('[CIDRAM] ' . $this->L10N->getString('response.Unable to write to the cache'));
            } else {
                $Status = $this->getStatusHTTP(503);
                header('HTTP/1.0 503 ' . $Status);
                header('HTTP/1.1 503 ' . $Status);
                header('Status: 503 ' . $Status);
                header('Retry-After: 3600');
                die;
            }
        }
    }

    /**
     * Search engine verification.
     *
     * @return void
     */
    public function searchEngineVerification(): void
    {
        if (empty($Permissions)) {
            static $Permissions = true;
            if (!isset($this->CIDRAM['VPermissions'])) {
                $this->CIDRAM['VPermissions'] = [];
            }
            $this->CIDRAM['VPermissions'] += array_flip(explode("\n", $this->Configuration['verification']['search_engines']));
        }
        $Before = $this->BlockInfo['SignatureCount'];
        $this->xVerification('Search Engine Verification', true);
        if (isset($this->Stages['SearchEngineVerification:Tracking']) && $this->BlockInfo['SignatureCount'] !== $Before) {
            $this->BlockInfo['Infractions'] += $this->BlockInfo['SignatureCount'] - $Before;
        }
    }

    /**
     * Social media verification.
     *
     * @return void
     */
    public function socialMediaVerification(): void
    {
        if (empty($Permissions)) {
            static $Permissions = true;
            if (!isset($this->CIDRAM['VPermissions'])) {
                $this->CIDRAM['VPermissions'] = [];
            }
            $this->CIDRAM['VPermissions'] += array_flip(explode("\n", $this->Configuration['verification']['social_media']));
        }
        $Before = $this->BlockInfo['SignatureCount'];
        $this->xVerification('Social Media Verification', false);
        if (isset($this->Stages['SocialMediaVerification:Tracking']) && $this->BlockInfo['SignatureCount'] !== $Before) {
            $this->BlockInfo['Infractions'] += $this->BlockInfo['SignatureCount'] - $Before;
        }
    }

    /**
     * Other verification.
     *
     * @return void
     */
    public function otherVerification(): void
    {
        if (empty($Permissions)) {
            static $Permissions = true;
            if (!isset($this->CIDRAM['VPermissions'])) {
                $this->CIDRAM['VPermissions'] = [];
            }
            $this->CIDRAM['VPermissions'] += array_flip(explode("\n", $this->Configuration['verification']['other']));
        }
        $Before = $this->BlockInfo['SignatureCount'];
        $this->xVerification('Other Verification', false);
        if (isset($this->Stages['OtherVerification:Tracking']) && $this->BlockInfo['SignatureCount'] !== $Before) {
            $this->BlockInfo['Infractions'] += $this->BlockInfo['SignatureCount'] - $Before;
        }
    }

    /**
     * Reset bypass flags.
     *
     * @return void
     */
    public function resetBypassFlags(): void
    {
        /** Guard. */
        if (!isset($this->CIDRAM['VerificationData'], $this->CIDRAM['VerificationData']['Search Engine Verification'])) {
            return;
        }

        foreach ($this->CIDRAM['VerificationData']['Search Engine Verification'] as $Values) {
            if (isset($Values['Bypass flag']) && strlen($Values['Bypass flag'])) {
                $this->CIDRAM[$Values['Bypass flag']] = false;
            }
        }
    }

    /**
     * Build any missing parts of the given path, apply date/time replacements, and
     * check whether the path is writable.
     *
     * @param string $Path The path we're building for.
     * @param bool $PointsToFile Whether the path ultimately points to a file or a
     *      directory.
     * @return string If all missing parts were successfully built and the final
     *      rebuilt path is writable, returns the final rebuilt path. Otherwise,
     *      returns an empty string.
     */
    public function buildPath(string $Path, bool $PointsToFile = true): string
    {
        /** Input guard. */
        if ($Path === '') {
            return '';
        }

        /** Applies time/date replacements. */
        $Path = $this->timeFormat($this->Now, $Path);

        /** We'll skip is_dir/mkdir calls if open_basedir is populated (to avoid PHP bug #69240). */
        $Restrictions = strlen(ini_get('open_basedir')) > 0;

        /** Split path into steps. */
        $Steps = preg_split('~[\\\\/]~', $Path, -1, PREG_SPLIT_NO_EMPTY);

        /** Separate file from path. */
        $File = $PointsToFile ? array_pop($Steps) : '';

        /** Build directories. */
        foreach ($Steps as $Step) {
            if (!isset($Rebuilt)) {
                $Rebuilt = preg_match('~^[\\\\/]~', $Path) ? DIRECTORY_SEPARATOR . $Step : $Step;
            } else {
                $Rebuilt .= DIRECTORY_SEPARATOR . $Step;
            }
            if (preg_match('~^\.+$~', $Step)) {
                continue;
            }
            if (!$Restrictions && !is_dir($Rebuilt) && !mkdir($Rebuilt)) {
                return '';
            }
        }

        /** Ensure rebuilt is defined. */
        if (!isset($Rebuilt)) {
            $Rebuilt = '';
        }

        /** Return an empty string if the final rebuilt path isn't writable. */
        if (!is_writable($Rebuilt)) {
            return '';
        }

        /** Append file. */
        if ($File) {
            $Rebuilt .= ($Rebuilt ? DIRECTORY_SEPARATOR : '') . $File;
        }

        /** Return the final rebuilt path. */
        return $Rebuilt;
    }

    /**
     * Checks whether the specified directory is empty.
     *
     * @param string $Directory The directory to check.
     * @return bool True if empty; False if not empty.
     */
    public function isDirEmpty(string $Directory): bool
    {
        return !((new \FilesystemIterator($Directory))->valid());
    }

    /**
     * Deletes empty directories (used by some front-end functions and log rotation).
     *
     * @param string $Dir The directory to delete.
     * @return void
     */
    public function deleteDirectory(string $Dir): void
    {
        while (strrpos($Dir, '/') !== false || strrpos($Dir, '\\') !== false) {
            $Separator = (strrpos($Dir, '/') !== false) ? '/' : '\\';
            $Dir = substr($Dir, 0, strrpos($Dir, $Separator));
            if (!is_dir($this->Vault . $Dir) || !$this->isDirEmpty($this->Vault . $Dir)) {
                break;
            }
            rmdir($this->Vault . $Dir);
        }
    }

    /**
     * Convert log file configuration directives to regular expressions.
     *
     * @param string $Str The log file configuration directive to work with.
     * @param bool $GZ Whether to include GZ files in the resulting expression.
     * @return string A corresponding regular expression.
     */
    public function buildLogPattern(string $Str, bool $GZ = false): string
    {
        return '~^' . preg_replace(
            ['~\\{(?:d|m|h|i|s)\\}~i', '~\\{(?:dd|mm|yy|hh|ii|ss)\\}~i', '~\\{yyyy\\}~i', '~\\{(?:Day|Mon)\\}~i', '~\\{tz\\}~i', '~\\{t:z\\}~i'],
            ['\d{1,2}', '\d{2}', '\d{4}', '\w{3}', '.{1,2}\d{4}', '.{1,2}\d{2}:\d{2}'],
            preg_replace([
                '~\\\\{yyyy\\\\}~i',
                '~\\\\{yy\\\\}~i',
                '~\\\\{mm\\\\}~i',
                '~\\\\{m\\\\}~i',
                '~\\\\{dd\\\\}~i',
                '~\\\\{d\\\\}~i',
                '~\\\\{hh\\\\}~i',
                '~\\\\{h\\\\}~i',
                '~\\\\{ii\\\\}~i',
                '~\\\\{i\\\\}~i',
                '~\\\\{ss\\\\}~i',
                '~\\\\{s\\\\}~i',
                '~\\\\{Mon\\\\}~i'
            ], [
                '(?<yyyy>\d{4})',
                '(?<yy>\d{2})',
                '(?<mm>\d{2})',
                '(?<m>\d{1,2})',
                '(?<dd>\d{2})',
                '(?<d>\d{1,2})',
                '(?<hh>\d{2})',
                '(?<h>\d{1,2})',
                '(?<ii>\d{2})',
                '(?<i>\d{1,2})',
                '(?<ss>\d{2})',
                '(?<s>\d{1,2})',
                '(?<Mon>\w{3})'
            ], preg_quote(str_replace('\\', '/', $Str)), 1)
        ) . ($GZ ? '(?:\.gz)?' : '') . '$~i';
    }

    /**
     * GZ-compress a file (used by log rotation).
     *
     * @param string $File The file to GZ-compress.
     * @return bool True on success; False on failure.
     */
    public function gZCompressFile(string $File): bool
    {
        /** Guard. */
        if ($File === '' || !is_file($File) || !is_readable($File)) {
            return false;
        }

        $Handle = fopen($File, 'rb');
        if (!is_resource($Handle)) {
            return false;
        }
        $HandleGZ = gzopen($File . '.gz', 'wb');
        if (!is_resource($HandleGZ)) {
            return false;
        }
        while (!feof($Handle)) {
            $Data = fread($Handle, self::FILE_BLOCKSIZE);
            gzwrite($HandleGZ, $Data);
        }
        gzclose($HandleGZ);
        fclose($Handle);
        return true;
    }

    /**
     * Log rotation.
     *
     * @param string $Pattern What to identify logs by (should be supplied via
     *      the relevant logging directive).
     * @return bool False when log rotation is disabled or errors occur; True
     *      otherwise.
     */
    public function logRotation(string $Pattern): bool
    {
        $Limit = $this->Configuration['logging']['log_rotation_limit'] ?? 0;
        $Action = $this->Configuration['logging']['log_rotation_action'] ?? '';
        if ($Limit < 1 || ($Action !== 'Delete' && $Action !== 'Archive')) {
            return false;
        }
        $Pattern = $this->buildLogPattern($Pattern);
        $Arr = [];
        $Offset = strlen($this->Vault);
        $List = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->Vault, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
        foreach ($List as $Item => $List) {
            $ItemFixed = str_replace('\\', '/', substr($Item, $Offset));
            if ($ItemFixed && preg_match($Pattern, $ItemFixed) && is_readable($Item)) {
                $Arr[$ItemFixed] = filemtime($Item);
            }
        }
        unset($ItemFixed, $List, $Offset);
        $Count = count($Arr);
        $Err = 0;
        if ($Count > $Limit) {
            asort($Arr, SORT_NUMERIC);
            $StageRestore = $this->Stage ?? '';
            $this->Stage = '';
            foreach ($Arr as $Item => $Modified) {
                if ($Action === 'Archive') {
                    $Err += !$this->gZCompressFile($this->Vault . $Item);
                }
                $Err += !unlink($this->Vault . $Item);
                if (strpos($Item, '/') !== false) {
                    $this->deleteDirectory($Item);
                }
                $Count--;
                if (!($Count > $Limit)) {
                    break;
                }
            }
            $this->Stage = $StageRestore;
        }
        return $Err === 0;
    }

    /**
     * Pseudonymise an IP address (reduce IPv4s to /24s and IPv6s to /32s).
     *
     * @param string $IP An IP address.
     * @return string A pseudonymised IP address.
     */
    public function pseudonymiseIp(string $IP): string
    {
        if (($CPos = strpos($IP, ':')) !== false) {
            $Parts = [(substr($IP, 0, $CPos) ?: ''), (substr($IP, $CPos + 1) ?: '')];
            if (($CPos = strpos($Parts[1], ':')) !== false) {
                $Parts[1] = substr($Parts[1], 0, $CPos) ?: '';
            }
            $Parts = $Parts[0] . ':' . $Parts[1] . '::x';
            return str_replace(':::', '::', $Parts);
        }
        return preg_replace(
            '/^([01]?\d{1,2}|2[0-4]\d|25[0-5])\.([01]?\d{1,2}|2[0-4]\d|25[0-5])\.([01]?\d{1,2}|2[0-4]\d|25[0-5])\.([01]?\d{1,2}|2[0-4]\d|25[0-5])$/',
            '\1.\2.\3.x',
            $IP
        );
    }

    /**
     * Fetch a status message from a HTTP status code for blocked requests.
     *
     * @param int $Status HTTP status code.
     * @return string HTTP status message (empty when using non-supported codes).
     */
    public function getStatusHTTP(int $Status): string
    {
        static $Message = [
            301 => 'Moved Permanently',
            302 => 'Found',
            307 => 'Temporary Redirect',
            308 => 'Permanent Redirect',
            403 => 'Forbidden',
            404 => 'Not Found',
            409 => 'Conflict',
            410 => 'Gone',
            418 => 'I\'m a teapot',
            429 => 'Too Many Requests',
            451 => 'Unavailable For Legal Reasons',
            503 => 'Service Unavailable'
        ];
        return $Message[$Status] ?? '';
    }

    /**
     * Used for matching auxiliary rule criteria.
     *
     * @param string|array $Criteria The criteria to accept for the match.
     * @param mixed $Actual The actual value we're trying to match.
     * @param string $Method The method for handling data when matching.
     * @param string $SourceName The name of the actual value's source.
     * @param string $Name The name of the rule (needed for inspection only).
     * @param bool $Negate Whether operators should be negated.
     * @return bool Match succeeded (true) or failed (false).
     */
    public function auxMatch($Criteria, $Actual, string $Method = '', string $SourceName = '', string $Name = '', bool $Negate = false): bool
    {
        /** Recurse through actual values if an array. */
        if (is_array($Actual)) {
            foreach ($Actual as $ThisActual) {
                if ($this->auxMatch($Criteria, $ThisActual, $Method, $SourceName, $Name, $Negate)) {
                    return true;
                }
            }
            return false;
        }

        /** Guard against non-scalar values. */
        if (!is_scalar($Actual)) {
            return false;
        }

        /** Normalise criteria to an array. */
        if (!is_array($Criteria)) {
            $Criteria = [$Criteria];
        }

        $ActualType = gettype($Actual);

        /** Iterate conditions. */
        foreach ($Criteria as $TestCase) {
            $this->CIDRAM['Last aux regex matches'] = [];
            if ($Method === 'Auto') {
                $Boundary = preg_quote(substr($TestCase, 0, 1));
                if (
                    preg_match('~^(?!\\\\)(?![\0-\x20\dA-Za-z\xC0-\xFF]).$~', $Boundary) &&
                    preg_match($Boundary === '~' ? '/^' . $Boundary . '.+' . $Boundary . 'i?m?s?x?A?D?S?U?u?n?$/' : '~^' . $Boundary . '.+' . $Boundary . 'i?m?s?x?A?D?S?U?u?n?$~', $TestCase)
                ) {
                    $Operator = $Negate ? '≇' : '≅';
                } else {
                    $Operator = strpos($TestCase, '*') === false ? $this->operatorFromAuxValue($TestCase, $Negate) : ($Negate ? '≉' : '≈');
                }
            } elseif ($Method === 'RegEx') {
                $Operator = $Negate ? '≇' : '≅';
            } elseif ($Method === 'WinEx') {
                $Operator = strpos($TestCase, '*') === false ? ($Negate ? '≠' : '=') : ($Negate ? '≉' : '≈');
            } else {
                $Operator = $this->operatorFromAuxValue($TestCase, $Negate);
            }

            /** Perform a match using regular expressions. */
            if ($Operator === '≇' || $Operator === '≅') {
                $Matches = [];
                if (preg_match($TestCase, $Actual, $Matches)) {
                    $this->CIDRAM['Last aux regex matches'] = $Matches;
                    $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Not satisfied' : 'response.Satisfied'));
                    return true;
                }
                $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Satisfied' : 'response.Not satisfied'));
                continue;
            }

            /** Perform a match using Windows-style wildcards. */
            if ($Operator === '≉' || $Operator === '≈') {
                if (preg_match('~^' . str_replace('\*', '.*', preg_quote($TestCase, '~')) . '$~', $Actual)) {
                    $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Not satisfied' : 'response.Satisfied'));
                    return true;
                }
                $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Satisfied' : 'response.Not satisfied'));
                continue;
            }

            /** Perform a match using direct string comparison. */
            if ($Operator === '≠' || $Operator === '=') {
                if ($ActualType !== gettype($TestCase)) {
                    if ($ActualType === 'integer') {
                        $TestCase = (int)preg_replace('~^\D*~', '', $TestCase);
                    } elseif ($ActualType === 'double') {
                        $TestCase = (float)preg_replace('~^\D*~', '', $TestCase);
                    } elseif ($ActualType === 'string') {
                        $TestCase = (string)$TestCase;
                    }
                }
                if ($Actual === $TestCase) {
                    $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Not satisfied' : 'response.Satisfied'));
                    return true;
                }
                $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Satisfied' : 'response.Not satisfied'));
                continue;
            }

            /** Greater than. */
            if ($Operator === '≯' || $Operator === '>') {
                if ($this->auxTestCaseToNumeric($Actual) > $this->auxTestCaseToNumeric($TestCase)) {
                    $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Not satisfied' : 'response.Satisfied'));
                    return true;
                }
                $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Satisfied' : 'response.Not satisfied'));
                continue;
            }

            /** Greater than or equal to. */
            if ($Operator === '≱' || $Operator === '≥') {
                if ($this->auxTestCaseToNumeric($Actual) >= $this->auxTestCaseToNumeric($TestCase)) {
                    $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Not satisfied' : 'response.Satisfied'));
                    return true;
                }
                $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Satisfied' : 'response.Not satisfied'));
                continue;
            }

            /** Less than. */
            if ($Operator === '≮' || $Operator === '<') {
                if ($this->auxTestCaseToNumeric($Actual) < $this->auxTestCaseToNumeric($TestCase)) {
                    $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Not satisfied' : 'response.Satisfied'));
                    return true;
                }
                $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Satisfied' : 'response.Not satisfied'));
                continue;
            }

            /** Less than or equal to. */
            if ($Operator === '≰' || $Operator === '≤') {
                if ($this->auxTestCaseToNumeric($Actual) <= $this->auxTestCaseToNumeric($TestCase)) {
                    $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Not satisfied' : 'response.Satisfied'));
                    return true;
                }
                $this->addInspectionEntry($Name, $SourceName . ' (' . $Actual . ') ' . $Operator . ' ' . $TestCase, $this->L10N->getString($Negate ? 'response.Satisfied' : 'response.Not satisfied'));
            }
        }

        /** Failed to match anything. */
        return false;
    }

    /**
     * Used for performing actions when an auxiliary rule matches.
     *
     * @param string $Action The type of action to perform.
     * @param string $Name The name of the rule.
     * @param string $Reason The reason for performing the action.
     * @param string $Target The target of the action (e.g., where to redirect).
     * @param int $StatusCode The status code to apply (if any has been set).
     * @param array $Webhooks Any webhooks included with the auxiliary rule.
     * @param array $Flags Any other options and special flags included with the auxiliary rule.
     * @param string $Run The name of the file to execute (when this action is used).
     * @return bool Whether the calling parent should return immediately.
     */
    public function auxAction(string $Action, string $Name, string $Reason = '', string $Target = '', int $StatusCode = 200, array $Webhooks = [], array $Flags = [], string $Run = ''): bool
    {
        /** In case errors occur. */
        $this->CIDRAM['ExtraErrorInfo'] = $Name;

        /** Apply webhooks. */
        if (!empty($Webhooks)) {
            $this->Webhooks = array_merge($this->Webhooks, $Webhooks);
        }

        /** Process other options and special flags. */
        foreach ($this->CIDRAM['Provide']['Auxiliary Rules']['Flags'] as $FlagSetName => $FlagSet) {
            foreach ($FlagSet as $FlagName => $FlagData) {
                if (empty($Flags[$FlagName]) || !isset($FlagData['Sets']) || !is_array($FlagData['Sets'])) {
                    continue;
                }
                foreach ($FlagData['Sets'] as $SetKey => $SetData) {
                    if (!property_exists($this, $SetKey)) {
                        continue;
                    }
                    $Property = &$this->$SetKey;
                    if (is_array($SetData) && is_array($Property)) {
                        $Property = array_replace_recursive($Property, $SetData);
                        continue;
                    }
                    $Property = $SetData;
                }
            }
        }

        /** Rule statistics. */
        if (!empty($Flags['Track this rule'])) {
            $this->Cache->incEntry('Statistics-Aux-' . $Name);
            $this->Cache->setEntry('Statistics-Aux-' . $Name . '-Most-Recent', $this->Now, 0);
        }

        /** Enqueue email trigger notification. */
        if (!empty($Flags['Email'])) {
            if (isset($this->CIDRAM['Trigger notifications'])) {
                $this->CIDRAM['Trigger notifications'] = [];
            }
            $this->CIDRAM['Trigger notifications'][] = $Name;
        }

        $RunExitCode = 0;
        if ($Action === 'Run' && $Run !== '') {
            if (!isset($this->CIDRAM['AuxRunResCache'])) {
                $this->CIDRAM['AuxRunResCache'] = [];
            }
            if (isset($this->CIDRAM['AuxRunResCache'][$Run]) && is_object($this->CIDRAM['AuxRunResCache'][$Run])) {
                $RunExitCode = $this->CIDRAM['AuxRunResCache'][$Run]();
            } elseif (!$this->isReserved($Run) && is_readable($this->Vault . $Run)) {
                require_once $this->Vault . $Run;
            } else {
                trigger_error($this->L10N->getString('response.Required files are missing'), E_USER_WARNING);
            }
        }

        /** Whitelist. */
        if ($Action === 'Whitelist' || $RunExitCode === 3) {
            $this->zeroOutBlockInfo(true);
            return true;
        }

        /** Greylist. */
        if ($Action === 'Greylist' || $RunExitCode === 2) {
            $this->zeroOutBlockInfo();
            return false;
        }

        /** Block. */
        if ($Action === 'Block' || $RunExitCode === 1) {
            $this->trigger(true, $Name, $Reason);
            if ($StatusCode > 400) {
                $this->CIDRAM['Aux Status Code'] = $StatusCode;
            }
            return false;
        }

        /** Bypass. */
        if ($Action === 'Bypass') {
            $this->bypass(true, $Name);
            return false;
        }

        /** Don't log the request instance. */
        if ($Action === 'Don\'t log') {
            $this->CIDRAM['Suppress logging'] = true;
            return false;
        }

        /** Redirect the request (without blocking it). */
        if ($Action === 'Redirect') {
            $this->CIDRAM['Aux Redirect'] = $Target;
            if ($StatusCode > 300 && $StatusCode < 400) {
                $this->CIDRAM['Aux Status Code'] = $StatusCode;
            }
            return false;
        }

        /** Profile the request. */
        if ($Action === 'Profile') {
            $this->addProfileEntry($Name);
        }

        /** Exit. */
        return false;
    }

    /**
     * Procedure for parsing and processing auxiliary rules.
     *
     * @return void
     */
    public function aux(): void
    {
        /** Exit procedure early if the rules don't exist. */
        if (!file_exists($this->Vault . 'auxiliary.yml')) {
            return;
        }

        /** Attempt to parse the auxiliary rules file. */
        if (!isset($this->CIDRAM['AuxData'])) {
            $this->CIDRAM['AuxData'] = [];
            $this->YAML->process($this->readFile($this->Vault . 'auxiliary.yml'), $this->CIDRAM['AuxData']);
        }

        /** Iterate through the auxiliary rules. */
        foreach ($this->CIDRAM['AuxData'] as $Name => $Data) {
            /** Safety. */
            if (!is_array($Data) || empty($Data) || !empty($Data['Disable this rule'])) {
                continue;
            }

            /** Skip not yet started rules. */
            if (
                isset($Data['From']) &&
                is_string($Data['From']) &&
                preg_match('~^(\d{4})[.-](\d\d)[.-](\d\d)$~', $Data['From'], $From)
            ) {
                $From = mktime(0, 0, 0, (int)$From[2], (int)$From[3], (int)$From[1]);
                if ($this->Now < $From) {
                    continue;
                }
            }

            /** Skip expired rules. */
            if (
                isset($Data['Expiry']) &&
                is_string($Data['Expiry']) &&
                preg_match('~^(\d{4})[.-](\d\d)[.-](\d\d)$~', $Data['Expiry'], $Expiry)
            ) {
                $Expiry = mktime(0, 0, 0, (int)$Expiry[2], (int)$Expiry[3], (int)$Expiry[1]);
                if ($this->Now > $Expiry) {
                    continue;
                }
            }

            /** Matching logic. */
            $Logic = (string)($Data['Logic'] ?? 'Any');

            /** Detailed reason. */
            $Reason = (string)($Data['Reason'] ?? $Name);

            /** The matching method to use. */
            $Method = (string)($Data['Method'] ?? '');

            /** Redirect target. */
            $Target = (string)($Data['Target'] ?? '');

            /** Run target. */
            $Run = (string)($Data['Run']['File'] ?? '');

            /** Status code to apply (if any has been specified). */
            $StatusCode = (int)($Data['Status Code'] ?? 200);

            /** Webhooks to apply (if any have been specified). */
            $Webhooks = $Data['Webhooks'] ?? [];
            $this->arrayify($Webhooks);

            /** Other options and special flags to apply (if any have been specified). */
            $Flags = [];
            foreach ($this->CIDRAM['Provide']['Auxiliary Rules']['Flags'] as $FlagSet) {
                foreach ($FlagSet as $FlagKey => $FlagData) {
                    $Flags[$FlagKey] = is_array($FlagData) && !empty($Data[$FlagKey]);
                }
            }

            /** Iterate through modes. */
            foreach ($this->CIDRAM['Provide']['Auxiliary Rules']['Modes'] as $Mode) {
                /** Skip mode if not used by this rule. */
                if (empty($Data[$Mode])) {
                    continue;
                }

                /** Flag for successful matches. */
                $Matched = ($Logic === 'All' && !empty($Data[$Mode]['But not if matches']) && empty($Data[$Mode]['If matches']));

                /** Match exceptions. */
                if (!empty($Data[$Mode]['But not if matches'])) {
                    /** Iterate through sources. */
                    foreach ($this->CIDRAM['Provide']['Auxiliary Rules']['Sources'] as $SourceArrKey => $SourceArr) {
                        if (!property_exists($this, $SourceArrKey)) {
                            continue;
                        }
                        $Property = &$this->$SourceArrKey;
                        if (is_array($SourceArr)) {
                            if (!is_array($Property)) {
                                continue;
                            }
                            foreach ($SourceArr as $SourceKey => $Source) {
                                $SourceName = $this->L10N->getString($Source) ?: $Source;
                                if (!isset($Data[$Mode]['But not if matches'][$SourceKey], $Property[$SourceKey])) {
                                    continue;
                                }
                                if (!is_array($Data[$Mode]['But not if matches'][$SourceKey])) {
                                    $Data[$Mode]['But not if matches'][$SourceKey] = [$Data[$Mode]['But not if matches'][$SourceKey]];
                                }
                                foreach ($Data[$Mode]['But not if matches'][$SourceKey] as $Value) {
                                    /** Perform match. */
                                    if ($this->auxMatch($Value, $Property[$SourceKey], $Method, $SourceName, $Name, true)) {
                                        continue 4;
                                    }
                                }
                            }
                            continue;
                        }
                        if (!isset($Data[$Mode]['But not if matches'][$SourceArrKey])) {
                            continue;
                        }
                        if (!is_array($Data[$Mode]['But not if matches'][$SourceArrKey])) {
                            $Data[$Mode]['But not if matches'][$SourceArrKey] = [$Data[$Mode]['But not if matches'][$SourceArrKey]];
                        }
                        $SourceName = $this->L10N->getString($SourceArr) ?: $SourceArr;
                        foreach ($Data[$Mode]['But not if matches'][$SourceArrKey] as $Value) {
                            /** Perform match. */
                            if ($this->auxMatch($Value, $Property, $Method, $SourceName, $Name, true)) {
                                continue 3;
                            }
                        }
                    }
                }

                /** Matches. */
                if (!empty($Data[$Mode]['If matches'])) {
                    /** Iterate through sources. */
                    foreach ($this->CIDRAM['Provide']['Auxiliary Rules']['Sources'] as $SourceArrKey => $SourceArr) {
                        if (!property_exists($this, $SourceArrKey)) {
                            continue;
                        }
                        $Property = &$this->$SourceArrKey;
                        if (is_array($SourceArr)) {
                            if (!is_array($Property)) {
                                continue;
                            }
                            foreach ($SourceArr as $SourceKey => $Source) {
                                $SourceName = $this->L10N->getString($Source) ?: $Source;
                                if (!isset($Data[$Mode]['If matches'][$SourceKey], $Property[$SourceKey])) {
                                    continue;
                                }
                                if (!is_array($Data[$Mode]['If matches'][$SourceKey])) {
                                    $Data[$Mode]['If matches'][$SourceKey] = [$Data[$Mode]['If matches'][$SourceKey]];
                                }
                                foreach ($Data[$Mode]['If matches'][$SourceKey] as $Value) {
                                    /** Perform match. */
                                    if ($this->auxMatch($Value, $Property[$SourceKey], $Method, $SourceName, $Name)) {
                                        $Matched = true;
                                        if ($Logic === 'All') {
                                            continue;
                                        }
                                        if (preg_match_all('~\\\\(\d+)~', $Name, $Counts) && isset($Counts[1], $this->CIDRAM['Last aux regex matches'][1])) {
                                            $Counts = array_unique($Counts[1]);
                                            foreach ($Counts as $Iterator) {
                                                if (isset($this->CIDRAM['Last aux regex matches'][$Iterator])) {
                                                    $Name = str_replace('\\' . $Iterator, $this->CIDRAM['Last aux regex matches'][$Iterator], $Name);
                                                }
                                            }
                                        }
                                        if (preg_match_all('~\\\\(\d+)~', $Reason, $Counts) && isset($Counts[1], $this->CIDRAM['Last aux regex matches'][1])) {
                                            $Counts = array_unique($Counts[1]);
                                            foreach ($Counts as $Iterator) {
                                                if (isset($this->CIDRAM['Last aux regex matches'][$Iterator])) {
                                                    $Reason = str_replace('\\' . $Iterator, $this->CIDRAM['Last aux regex matches'][$Iterator], $Reason);
                                                }
                                            }
                                        }
                                        $ReturnImmediately = $this->auxAction($Mode, $Name, $Reason, $Target, $StatusCode, $Webhooks, $Flags, $Run);
                                        if (isset($Data['Additional instructions'])) {
                                            $this->OperationHandler->set($this, $Data['Additional instructions'], true);
                                        }
                                        if ($ReturnImmediately) {
                                            return;
                                        }
                                        continue 4;
                                    } elseif ($Logic === 'All') {
                                        continue 4;
                                    }
                                }
                            }
                            continue;
                        }
                        if (!isset($Data[$Mode]['If matches'][$SourceArrKey])) {
                            continue;
                        }
                        if (!is_array($Data[$Mode]['If matches'][$SourceArrKey])) {
                            $Data[$Mode]['If matches'][$SourceArrKey] = [$Data[$Mode]['If matches'][$SourceArrKey]];
                        }
                        $SourceName = $this->L10N->getString($SourceArr) ?: $SourceArr;
                        foreach ($Data[$Mode]['If matches'][$SourceArrKey] as $Value) {
                            /** Perform match. */
                            if ($this->auxMatch($Value, $Property, $Method, $SourceName, $Name)) {
                                $Matched = true;
                                if ($Logic === 'All') {
                                    continue;
                                }
                                if (preg_match_all('~\\\\(\d+)~', $Name, $Counts) && isset($Counts[1], $this->CIDRAM['Last aux regex matches'][1])) {
                                    $Counts = array_unique($Counts[1]);
                                    foreach ($Counts as $Iterator) {
                                        if (isset($this->CIDRAM['Last aux regex matches'][$Iterator])) {
                                            $Name = str_replace('\\' . $Iterator, $this->CIDRAM['Last aux regex matches'][$Iterator], $Name);
                                        }
                                    }
                                }
                                if (preg_match_all('~\\\\(\d+)~', $Reason, $Counts) && isset($Counts[1], $this->CIDRAM['Last aux regex matches'][1])) {
                                    $Counts = array_unique($Counts[1]);
                                    foreach ($Counts as $Iterator) {
                                        if (isset($this->CIDRAM['Last aux regex matches'][$Iterator])) {
                                            $Reason = str_replace('\\' . $Iterator, $this->CIDRAM['Last aux regex matches'][$Iterator], $Reason);
                                        }
                                    }
                                }
                                $ReturnImmediately = $this->auxAction($Mode, $Name, $Reason, $Target, $StatusCode, $Webhooks, $Flags, $Run);
                                if (isset($Data['Additional instructions'])) {
                                    $this->OperationHandler->set($this, $Data['Additional instructions'], true);
                                }
                                if ($ReturnImmediately) {
                                    return;
                                }
                                continue 3;
                            } elseif ($Logic === 'All') {
                                continue 3;
                            }
                        }
                    }
                }

                /** Perform action for matching rules requiring all conditions to be met. */
                if ($Logic === 'All' && $Matched) {
                    if (preg_match_all('~\\\\(\d+)~', $Name, $Counts) && isset($Counts[1], $this->CIDRAM['Last aux regex matches'][1])) {
                        $Counts = array_unique($Counts[1]);
                        foreach ($Counts as $Iterator) {
                            if (isset($this->CIDRAM['Last aux regex matches'][$Iterator])) {
                                $Name = str_replace('\\' . $Iterator, $this->CIDRAM['Last aux regex matches'][$Iterator], $Name);
                            }
                        }
                    }
                    if (preg_match_all('~\\\\(\d+)~', $Reason, $Counts) && isset($Counts[1], $this->CIDRAM['Last aux regex matches'][1])) {
                        $Counts = array_unique($Counts[1]);
                        foreach ($Counts as $Iterator) {
                            if (isset($this->CIDRAM['Last aux regex matches'][$Iterator])) {
                                $Reason = str_replace('\\' . $Iterator, $this->CIDRAM['Last aux regex matches'][$Iterator], $Reason);
                            }
                        }
                    }
                    $ReturnImmediately = $this->auxAction($Mode, $Name, $Reason, $Target, $StatusCode, $Webhooks, $Flags, $Run);
                    if (isset($Data['Additional instructions'])) {
                        $this->OperationHandler->set($this, $Data['Additional instructions'], true);
                    }
                    if ($ReturnImmediately) {
                        return;
                    }
                }
            }
        }
    }

    /**
     * Get operator from auxiliary rule values.
     *
     * @param string $Value The auxiliary rule value.
     * @param bool $Negate Whether to negate the operator.
     * @return string The operator.
     */
    public function operatorFromAuxValue(string &$Value, bool $Negate = false): string
    {
        $Try = $Value;
        $Stub = substr($Try, 0, 1);
        if ($Stub === '&') {
            if (substr($Try, 0, 4) === '&lt;') {
                $Stub = '<';
                $Try = substr($Try, 3);
            } elseif (substr($Try, 0, 4) === '&gt;') {
                $Stub = '>';
                $Try = substr($Try, 3);
            }
        }
        if ($Stub === '>') {
            $Try = substr($Try, 1);
            $Stub = substr($Try, 0, 1);
            if ($Stub === '=') {
                $Try = substr($Try, 1);
                if (is_numeric($Try)) {
                    $Value = $Try;
                    return $Negate ? '≱' : '≥';
                }
                return $Negate ? '≠' : '=';
            }
            if (is_numeric($Try)) {
                $Value = $Try;
                return $Negate ? '≯' : '>';
            }
            return $Negate ? '≠' : '=';
        }
        if ($Stub === '<') {
            $Try = substr($Try, 1);
            $Stub = substr($Try, 0, 1);
            if ($Stub === '=') {
                $Try = substr($Try, 1);
                if (is_numeric($Try)) {
                    $Value = $Try;
                    return $Negate ? '≰' : '≤';
                }
                return $Negate ? '≠' : '=';
            }
            if (is_numeric($Try)) {
                $Value = $Try;
                return $Negate ? '≮' : '<';
            }
            return $Negate ? '≠' : '=';
        }
        if ($Stub === "\xe2") {
            $Stub = substr($Try, 1, 2);
            if ($Stub === "\x89\xa5") {
                $Try = substr($Try, 3);
                if (is_numeric($Try)) {
                    $Value = $Try;
                    return $Negate ? '≱' : '≥';
                }
                return $Negate ? '≠' : '=';
            }
            if ($Stub === "\x89\xa4") {
                $Try = substr($Try, 3);
                if (is_numeric($Try)) {
                    $Value = $Try;
                    return $Negate ? '≰' : '≤';
                }
            }
        }
        return $Negate ? '≠' : '=';
    }

    /**
     * Discern numeric value from the supplied input.
     *
     * @param mixed $Value The supplied input.
     * @return int|float A numeric value.
     */
    public function auxTestCaseToNumeric($Value)
    {
        /** Already numeric. */
        if (is_int($Value) || is_float($Value)) {
            return $Value;
        }

        /** Guard against non-scalar values which could break the process. */
        if (!is_scalar($Value)) {
            return 0;
        }

        if (!is_string($Value)) {
            $Value = (string)$Value;
        }

        /** Convert IPv4 address to numeric value. */
        if (preg_match('~^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$~', $Value)) {
            $Value = explode('.', $Value);
            return ($Value[0] * 16777216) + ($Value[1] * 65536) + ($Value[2] * 256) + $Value[3];
        }

        /** Doesn't seem to be anything special here, so we'll just cast directly to int. */
        return (int)$Value;
    }

    /**
     * Fetch rate limiting data.
     *
     * @return void
     */
    public function rateLimitFetch(): void
    {
        $SourceName = ($this->Configuration['rate_limiting']['segregate'] && $this->CIDRAM['HTTP_HOST'] !== '') ? 'rl-' . $this->CIDRAM['HTTP_HOST'] : 'rl';

        /** Override if using a different preferred caching mechanism. */
        if ($this->Cache->Using && $this->Cache->Using !== 'FF') {
            $this->CIDRAM['RL_Data'] = $this->Cache->getEntry($SourceName);
            return;
        }

        /** Default process. */
        $this->CIDRAM['RL_Data'] = $this->readFile($this->Vault . $SourceName . '.dat');
    }

    /**
     * Write an access event to the rate limiting cache.
     *
     * @param string $RL_Capture What we've captured to identify the requesting entity.
     * @param int $RL_Size The size of the output served to the requesting entity.
     * @return void
     */
    public function rateLimitWriteEvent(string $RL_Capture, int $RL_Size): void
    {
        /** Guard. */
        if (isset($this->CIDRAM['ViewCalled']) && preg_match('~(?:^|\n)FE(?:\n|$)~', $this->Configuration['rate_limiting']['exceptions'])) {
            return;
        }

        $SourceName = ($this->Configuration['rate_limiting']['segregate'] && $this->CIDRAM['HTTP_HOST'] !== '') ? 'rl-' . $this->CIDRAM['HTTP_HOST'] : 'rl';
        $TimePacked = pack('l*', $this->Now);
        $SizePacked = pack('l*', $RL_Size);
        $Data = $TimePacked . $SizePacked . $RL_Capture;

        /** Override if using a different preferred caching mechanism. */
        if ($this->Cache->Using && $this->Cache->Using !== 'FF') {
            $this->Cache->setEntry($SourceName, $this->CIDRAM['RL_Data'] . $Data, $this->Configuration['rate_limiting']['allowance_period']->getAsSeconds());
            return;
        }

        /** Default process. */
        $Handle = fopen($this->Vault . $SourceName . '.dat', 'ab');
        fwrite($Handle, $Data);
        fclose($Handle);
    }

    /**
     * Remove outdated access events from the rate limiting cache.
     *
     * @return void
     */
    public function rateLimitClean(): void
    {
        /** Guard. */
        if (!isset($this->CIDRAM['RL_Data'], $this->CIDRAM['RL_Expired'])) {
            return;
        }

        $Pos = 0;
        $EoS = strlen($this->CIDRAM['RL_Data']);
        while ($Pos < $EoS) {
            $Time = substr($this->CIDRAM['RL_Data'], $Pos, 4);
            if (strlen($Time) !== 4) {
                break;
            }
            $Time = unpack('l*', $Time);
            if ($Time[1] > $this->CIDRAM['RL_Expired']) {
                break;
            }
            $Pos += 8;
            $Block = substr($this->CIDRAM['RL_Data'], $Pos, 4);
            if (strlen($Block) !== 4) {
                $this->CIDRAM['RL_Data'] = '';
                break;
            }
            $Block = unpack('l*', $Block);
            $Pos += 4 + $Block[1];
        }
        if ($Pos) {
            $SourceName = ($this->Configuration['rate_limiting']['segregate'] && $this->CIDRAM['HTTP_HOST'] !== '') ? 'rl-' . $this->CIDRAM['HTTP_HOST'] : 'rl';
            if ($this->CIDRAM['RL_Data']) {
                $this->CIDRAM['RL_Data'] = substr($this->CIDRAM['RL_Data'], $Pos);
            }

            /** Override if using a different preferred caching mechanism. */
            if ($this->Cache->Using && $this->Cache->Using !== 'FF') {
                $this->Cache->setEntry($SourceName, $this->CIDRAM['RL_Data'], $this->Configuration['rate_limiting']['allowance_period']->getAsSeconds());
                return;
            }

            /** Default process. */
            $Handle = fopen($this->Vault . $SourceName . '.dat', 'wb');
            fwrite($Handle, $this->CIDRAM['RL_Data']);
            fclose($Handle);
        }
    }

    /**
     * Count the requesting entity's requests and bandwidth usage for this period.
     *
     * @return array The requesting entity's requests and bandwidth usage for this period.
     */
    public function rateGetUsage(): array
    {
        $Pos = 0;
        $Bytes = 0;
        $Requests = 0;
        while (strlen($this->CIDRAM['RL_Data']) > $Pos && $Pos = strpos($this->CIDRAM['RL_Data'], $this->CIDRAM['RL_Capture'], $Pos + 1)) {
            if ($Pos === false) {
                break;
            }
            $Size = substr($this->CIDRAM['RL_Data'], $Pos - 4, 4);
            if (strlen($Size) !== 4) {
                break;
            }
            $Size = unpack('l*', $Size);
            $Bytes += $Size[1];
            $Requests++;
        }
        return ['Bytes' => $Bytes, 'Requests' => $Requests];
    }

    /**
     * Get the requesting entity's oldest record.
     *
     * @return array The requesting entity's oldest record.
     */
    public function rateGetOldest(): array
    {
        if (($Pos = strpos($this->CIDRAM['RL_Data'], $this->CIDRAM['RL_Capture'])) !== false) {
            $Bytes = unpack('l*', substr($this->CIDRAM['RL_Data'], $Pos - 4, 4));
            $Time = unpack('l*', substr($this->CIDRAM['RL_Data'], $Pos - 8, 4));
        }
        return ['Bytes' => $Bytes[1] ?? 0, 'Time' => $Time[1] ?? 0];
    }

    /**
     * Initialises an error handler to catch any errors generated by CIDRAM when
     * needed.
     *
     * @return void
     */
    public function initialiseErrorHandler(): void
    {
        /** Stores any errors generated by the error handler. */
        $this->CIDRAM['Errors'] = [];

        /**
         * For a full description of all supported parameters, please see:
         * @link https://php.net/set_error_handler
         *
         * @param int $errno
         * @param string $errstr
         * @param string $errfile
         * @param int $errline
         * @return bool True to end further processing; False to defer processing.
         */
        $this->CIDRAM['PreviousErrorHandler'] = set_error_handler(function ($errno, $errstr, $errfile, $errline) {
            $VaultLen = strlen($this->Vault);
            if (
                strlen($errfile) > $VaultLen &&
                str_replace('\\', '/', substr($errfile, 0, $VaultLen)) === str_replace('\\', '/', $this->Vault)
            ) {
                $errfile = substr($errfile, $VaultLen);
            }
            $ExtraErrorInfo = $this->CIDRAM['ExtraErrorInfo'] ?? '';
            $this->CIDRAM['Errors'][] = [$errno, $errstr, $errfile, $errline, $ExtraErrorInfo];
            $this->CIDRAM['ExtraErrorInfo'] = '';
            if ($this->Events->assigned('error')) {
                $this->Events->fireEvent('error', serialize([$errno, $errstr, $errfile, $errline]));
            }
            return true;
        });
    }

    /**
     * Restores previous error handler after having initialised an error handler.
     *
     * @return void
     */
    public function restoreErrorHandler(): void
    {
        /** Reset errors array. */
        $this->CIDRAM['Errors'] = [];

        /** Restore previous error handler. */
        restore_error_handler();
    }

    /**
     * Generates unique IDs for block events.
     *
     * @return string A unique ID to use for block events.
     */
    public function generateId(): string
    {
        $Time = explode(' ', microtime(), 2);
        $Time[0] = (string)($Time[0] * 1000000);
        while (strlen($Time[0]) < 6) {
            $Time[0] = '0' . $Time[0];
        }

        /** PHP >= 7.3 (https://www.php.net/manual/en/function.hrtime.php */
        if (function_exists('hrtime')) {
            try {
                $HRTime = (string)hrtime(true);
                if (strlen($HRTime) > 10) {
                    $HRTime = substr($HRTime, -10);
                }
                while (strlen($HRTime) < 10) {
                    $HRTime = '0' . $HRTime;
                }
            } catch (\Exception $Exception) {
                $HRTime = '';
            }
        } else {
            $HRTime = '';
        }

        $HRLen = strlen($HRTime);
        $Time = $Time[1] . '-' . $Time[0] . '-' . $HRTime;
        if ($HRLen < 10) {
            $Low = 10 ** (9 - strlen($HRTime));
            $High = ($Low * 10) - 1;
            try {
                $Pad = random_int($Low, $High);
            } catch (\Exception $Exception) {
                $Pad = rand($Low, $High);
            }
            $Time .= $Pad;
        }
        return $Time;
    }

    /**
     * Adds entries to the profiles list.
     *
     * @param string $Entries The entries to add.
     * @return void
     */
    public function addProfileEntry(string $Entries): void
    {
        foreach (explode(';', $Entries) as $Profile) {
            $this->Profiles[] = $Profile;
        }
        sort($this->Profiles, SORT_STRING);
        $this->Profiles = array_unique($this->Profiles);
    }

    /**
     * Checks whether the instance has a profile.
     *
     * @param string|array $Profile The profile to check.
     * @return bool True if it has it; False if it doesn't.
     */
    public function hasProfile($Profile): bool
    {
        if (is_array($Profile)) {
            foreach ($Profile as $Checking) {
                if ($this->hasProfile($Checking)) {
                    return true;
                }
            }
            return false;
        }
        if (!is_string($Profile)) {
            return false;
        }
        foreach ($this->Profiles as $Has) {
            if ($Has === $Profile) {
                return true;
            }
        }
        return false;
    }

    /**
     * Determine, based on current signature count and on whether any CAPTCHA
     * solutions are enabled, whether to honour API lookups. Ensures compatibility
     * with modules written with v3 code in mind.
     *
     * @return bool True to honour; False to not honour.
     */
    public function honourLookup(): bool
    {
        if (!isset($this->BlockInfo['SignatureCount'])) {
            return false;
        }
        if (isset($this->Configuration['recaptcha']['usemode']) && (
            $this->Configuration['recaptcha']['usemode'] === 1 ||
            $this->Configuration['recaptcha']['usemode'] === 3 ||
            (
                (
                    $this->Configuration['recaptcha']['usemode'] === 2 ||
                    $this->Configuration['recaptcha']['usemode'] === 5
                ) && !empty($this->Configuration['recaptcha']['enabled'])
            )
        )) {
            return $this->BlockInfo['SignatureCount'] <= $this->Configuration['recaptcha']['signature_limit'];
        }
        if (isset($this->Configuration['hcaptcha']['usemode']) && (
            $this->Configuration['hcaptcha']['usemode'] === 1 ||
            $this->Configuration['hcaptcha']['usemode'] === 3 ||
            (
                (
                    $this->Configuration['hcaptcha']['usemode'] === 2 ||
                    $this->Configuration['hcaptcha']['usemode'] === 5
                ) && !empty($this->Configuration['hcaptcha']['enabled'])
            )
        )) {
            return $this->BlockInfo['SignatureCount'] <= $this->Configuration['hcaptcha']['signature_limit'];
        }
        return $this->BlockInfo['SignatureCount'] < 1;
    }

    /**
     * Determine, based on the URI of the current request, whether the current
     * request is sensitive.
     *
     * @param string $URI The URI.
     * @return bool True is sensitive; False is not sensitive.
     */
    public function isSensitive(string $URI): bool
    {
        if (isset($this->CIDRAM['isSensitive'])) {
            return $this->CIDRAM['isSensitive'];
        }
        foreach (explode("\n", $this->Configuration['general']['sensitive']) as $Try) {
            $First = substr($Try, 0, 1);
            if ($First === '/') {
                $PathOnly = $_SERVER['REQUEST_URI'] ?? '/';
                if ($Try === substr($PathOnly, 0, strlen($Try))) {
                    return $this->CIDRAM['isSensitive'] = true;
                }
            } elseif (!preg_match('~^[\dA-Za-z]$~', $First) && $First === substr(preg_replace('~[a-z]+$~', '', $Try), -1)) {
                if (preg_match($Try, $URI)) {
                    return $this->CIDRAM['isSensitive'] = true;
                }
            } elseif (strpos($URI, $Try) !== false) {
                return $this->CIDRAM['isSensitive'] = true;
            }
        }
        return $this->CIDRAM['isSensitive'] = false;
    }

    /**
     * Load L10N data.
     *
     * @param string $Path Where to find the L10N data to load.
     * @return void
     */
    public function loadL10N(string $Path = ''): void
    {
        if (($Primary = $this->readFile($Path . $this->Configuration['general']['lang'] . '.yml')) === '') {
            if (isset($this->CIDRAM['Config Defaults']['general']['lang']['defer'][$this->Configuration['general']['lang']])) {
                if (($Primary = $this->readFile($Path . $this->CIDRAM['Config Defaults']['general']['lang']['defer'][$this->Configuration['general']['lang']] . '.yml')) === '') {
                    $Primary = $this->readFile($Path . preg_replace('~-.*$~', '', $this->CIDRAM['Config Defaults']['general']['lang']['defer'][$this->Configuration['general']['lang']]) . '.yml');
                }
            }
            if ($Primary === '') {
                $Try = preg_replace('~-.*$~', '', $this->Configuration['general']['lang']);
                if (($Primary = $this->readFile($Path . $Try . '.yml')) === '') {
                    if (isset($this->CIDRAM['Config Defaults']['general']['lang']['defer'][$Try])) {
                        $Primary = $this->readFile($Path . $this->CIDRAM['Config Defaults']['general']['lang']['defer'][$Try] . '.yml');
                    }
                }
            }
        }
        if ($Primary !== '') {
            $Accepted = $this->CIDRAM['Config Defaults']['general']['lang']['assume'][$this->Configuration['general']['lang']] ?? $this->Configuration['general']['lang'];
            $Arr = [];
            $this->YAML->process($Primary, $Arr);
            $Primary = $Arr;
        } else {
            $Accepted = '';
            $Primary = [];
        }
        if ($this->L10NAccepted === '' && $Accepted !== '') {
            $this->L10NAccepted = $Accepted;
        }
        $Fallback = substr($this->L10NAccepted, 0, 3) === 'en-' ? '' : $this->readFile($Path . 'en.yml');
        if ($Fallback !== '') {
            $Arr = [];
            $this->YAML->process($Fallback, $Arr);
            $Fallback = $Arr;
        } else {
            $Fallback = [];
        }

        /** Instantiate the L10N object, or append to the instance if it already exists. */
        if ($this->L10N instanceof \Maikuolan\Common\L10N && is_array($this->L10N->Data)) {
            if (!empty($Primary) && is_array($this->L10N->Data)) {
                $this->L10N->Data = array_merge_recursive($this->L10N->Data, $Primary);
            }
            if (!empty($Fallback) && is_array($this->L10N->Fallback)) {
                $this->L10N->Fallback = array_merge_recursive($this->L10N->Fallback, $Fallback);
            }
        } else {
            $this->L10N = new \Maikuolan\Common\L10N($Primary, $Fallback);
            if (substr($this->L10NAccepted, 0, 3) === 'en-') {
                $this->L10N->autoAssignRules($this->L10NAccepted);
            } else {
                $this->L10N->autoAssignRules($this->L10NAccepted, 'en-AU');
            }
            $this->L10N->PreferredVariant = $this->CIDRAM['Config Defaults']['general']['lang']['defer'][$this->L10NAccepted] ?? $this->L10NAccepted;
        }

        /** Load client-specified L10N data if possible. */
        if (!$this->Configuration['general']['lang_override'] || empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
            if (!($this->ClientL10N instanceof \Maikuolan\Common\L10N)) {
                $this->ClientL10N = &$this->L10N;
            }
        } else {
            $Try = explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE'], 20);
            $Accepted = '';
            foreach ($Try as $Accepted) {
                $Accepted = preg_replace(['~;.*$~', '~[^-A-Za-z]|-$~'], '', $Accepted);
                $Primary = '';
                $IsSameAs = false;
                if ($this->L10NAccepted === $Accepted) {
                    $IsSameAs = true;
                    break;
                }
                $Main = strpos($Accepted, '-') === false ? '' : strtolower(preg_replace('~-.*$~', '', $Accepted));
                if (($Primary = $this->readFile($Path . $Accepted . '.yml')) !== '' || ($Primary = $this->readFile($Path . $Main . '.yml')) !== '') {
                    break;
                }
                foreach ([$Accepted, $Main] as $Accepted) {
                    if ($Accepted === '' || !isset($this->CIDRAM['Config Defaults']['general']['lang']['defer'][$Accepted])) {
                        break;
                    }
                    if ($this->L10NAccepted === $this->CIDRAM['Config Defaults']['general']['lang']['defer'][$Accepted]) {
                        $IsSameAs = true;
                        break 2;
                    }
                    if (
                        ($Primary = $this->readFile($Path . $this->CIDRAM['Config Defaults']['general']['lang']['defer'][$Accepted] . '.yml')) !== '' ||
                        ($Primary = $this->readFile($Path . preg_replace('~-.*$~', '', $this->CIDRAM['Config Defaults']['general']['lang']['defer'][$Accepted]) . '.yml')) !== ''
                    ) {
                        break 2;
                    }
                }
            }
            if ($Primary !== '') {
                $Accepted = $this->CIDRAM['Config Defaults']['general']['lang']['assume'][$Accepted] ?? $Accepted;
            }

            /** Process client-specified L10N data. */
            if ($IsSameAs) {
                if (!($this->ClientL10N instanceof \Maikuolan\Common\L10N)) {
                    $this->ClientL10N = &$this->L10N;
                    $this->ClientL10NAccepted = &$this->L10NAccepted;
                }
            } elseif ($Primary !== '') {
                $Arr = [];
                if ($this->ClientL10NAccepted === '' && $Accepted !== '') {
                    $this->ClientL10NAccepted = $Accepted;
                }
                $this->YAML->process($Primary, $Arr);
                if ($this->ClientL10N instanceof \Maikuolan\Common\L10N && is_array($this->ClientL10N->Data)) {
                    $this->ClientL10N->Data = array_merge_recursive($this->ClientL10N->Data, $Arr);
                } else {
                    $this->ClientL10N = new \Maikuolan\Common\L10N($Arr, $this->L10N);
                    $this->ClientL10N->autoAssignRules($Accepted);
                    $this->ClientL10N->PreferredVariant = $this->CIDRAM['Config Defaults']['general']['lang']['defer'][$Accepted] ?? $Accepted;
                }
            } elseif (!($this->ClientL10N instanceof \Maikuolan\Common\L10N)) {
                $this->ClientL10N = new \Maikuolan\Common\L10N([], $this->L10N);
            }
        }

        /** Fallback for missing accepted client L10N choice. */
        if ($this->ClientL10NAccepted === '') {
            $this->ClientL10NAccepted = $this->L10NAccepted;
            $this->ClientL10N->PreferredVariant = $this->CIDRAM['Config Defaults']['general']['lang']['defer'][$this->L10NAccepted] ?? $this->L10NAccepted;
        }
    }

    /**
     * Fetch favicon.
     *
     * @param string $Theme The theme to fetch the favicon from.
     * @return array The base64-encoded favicon and its extension.
     */
    public function fetchFavicon(string $Theme = 'default'): array
    {
        foreach (['ico', 'png', 'jpg', 'gif'] as $Extension) {
            if (!is_readable($this->Vault . 'favicon_' . $Theme . '.' . $Extension)) {
                continue;
            }
            return [
                base64_encode($this->readFile($this->Vault . 'favicon_' . $Theme . '.' . $Extension)),
                $Extension === 'ico' ? 'x-icon' : $Extension
            ];
        }
        return [
            'AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' .
            'AAAAAAAAAABmAAAAmQDu7u4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' .
            'AAAAAAAAAAAAAAAAAAAAERERERERAAETETExMTEQARMRMTExMRABEzMxMTExEAETETExMTEQAREzETMz' .
            'ERABEREREREREAIiIiIiIiIgAjMjIjMyMiADIiMjIjIyIAMiIyMiMjMgAjMiIjMyMjACIiMiIjIiIAAi' .
            'IiIiIiIAAAAAAAAAAACAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' .
            'AAAAAAAAAAAAAAAAAACAAQAA',
            'x-icon'
        ];
    }

    /**
     * Get canonical path (but not checking whether it's real).
     *
     * @param string $Path The path to check.
     * @return string The canonicalised path.
     */
    public function canonical(string $Path): string
    {
        $Path = str_replace('\\', '/', $Path);
        while (preg_match('~/[^/]+/\.\./|/\./|/{2,}~', $Path)) {
            $Path = preg_replace('~/[^/]+/\.\./|/\./|/{2,}~', '/', $Path);
        }
        return $Path;
    }

    /**
     * Check whether a name is reserved. Important, because attempting to read
     * from, write to, or otherwise work with names reserved at the file system
     * can result in unexpected behaviour and potential security risks.
     *
     * @param string $Name The name to check.
     * @return bool True if reserved; False if not.
     */
    public function isReserved(string $Name): bool
    {
        return preg_match('~(?:^|\\\\|/)(?:\.{1,3}|aux|com(?:\d+|¹|²|³)|con|lpt(?:\d+|¹|²|³)|nul|prn)(?:(?:\..*)?$|\\\\|/)|[ .]$~i', $Name);
    }

    /**
     * Traversal detection.
     *
     * @param string $Path The path to check for traversal.
     * @return bool True when the path is traversal-free. False when traversal has been detected.
     */
    public function freeFromTraversal(string $Path): bool
    {
        return !preg_match(
            '~(?:[^\da-z\p{L}\p{N}\p{M}\p{P}\p{S}\p{Z}.]|[\\/?&=]|^)\.\.+(?:[^\da-z\p{L}\p{N}\p{M}\p{P}\p{S}\p{Z}.]|[\\/?&=]|$)|/\.+(?:[^\da-z\p{L}\p{N}\p{M}\p{P}\p{S}\p{Z}.]|[\\/?&=]|$)|(?:[^\da-z\p{L}\p{N}\p{M}\p{P}\p{S}\p{Z}.]|[\\/])\.+/|[\x01-\x1F]~i',
            str_ireplace(
                ['%25', '%20', '%21', '%22', '%23', '%24', '%26', '%27', '%28', '%29', '%2a', '%2b', '%2c', '%2d', '%2e', '%2f', '%3a', '%3b', '%3c', '%3d', '%3e', '%3f', '%5b', '%5c', '%5d', '%5e', '%5f', '%60', '\\'],
                ['%', ' ', '!', '"', '#', '$', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '<', '=', '>', '?', '[', '/', ']', '^', '_', '`', '/'],
                $Path
            )
        );
    }

    /**
     * Routes from uaIpMatch, uaCidrMatch, and uaAsnMatch. Has no return value.
     *
     * @param mixed $Datapoints The datapoint to be matched.
     * @param string|array $Expected The expected values (per the call origin).
     * @param string $Friendly A friendly name to use when logging.
     * @return void
     */
    private function uaXMatch($Datapoints, $Expected, string $Friendly): void
    {
        $this->arrayify($Datapoints);
        $this->arrayify($Expected);

        /** Compare the actual value from the request against the expected values. */
        foreach ($Datapoints as $Datapoint) {
            if (in_array($Datapoint, $Expected)) {
                /** Untrack positives. */
                if (isset($this->CIDRAM['VPermissions'][$Friendly . ':UntrackPositives'])) {
                    $this->CIDRAM['Trackable'] = false;
                }

                /** Populate "verified" field. */
                if (isset($this->BlockInfo['Verified'])) {
                    $this->BlockInfo['Verified'] = $Friendly;
                    $this->addProfileEntry('Verified');
                }

                /** Single-hit bypass. */
                $this->bypass((
                    isset($this->CIDRAM['VPermissions'][$Friendly . ':SingleHitBypass'], $this->BlockInfo['SignatureCount'], $this->BlockInfo['WhyReason']) &&
                    $this->BlockInfo['SignatureCount'] === 1 &&
                    preg_match('~, L\d+:F\d+,| Lookup~', $this->BlockInfo['WhyReason'])
                ), $this->L10N->getString('why_single_hit_bypass'));

                /** Exit. */
                return;
            }
        }

        /** Nothing matched. Block it. */
        if (isset($this->CIDRAM['VPermissions'][$Friendly . ':BlockNegatives'])) {
            $this->trigger(true, sprintf($this->L10N->getString('Short.Fake_UA'), $Friendly));
            $this->addProfileEntry('Blocked Negative');

            /** Reporting. */
            $this->Reporter->report([19], ['Caught masquerading as ' . $Friendly . '.'], $this->BlockInfo['IPAddr']);
        }
    }

    /**
     * Master method for search engine and social media verification.
     *
     * @param string $From Where we're seeking the verification data from.
     * @param bool $BypassFlags Whether to check for bypass flags.
     * @return void
     */
    private function xVerification(string $From = '', bool $BypassFlags = false): void
    {
        if (
            empty($this->CIDRAM['TestResults']) ||
            isset($this->CIDRAM['SkipVerification']) ||
            empty($this->BlockInfo['UALC'])
        ) {
            return;
        }
        if (!isset($this->CIDRAM['VerificationData'])) {
            if (($Raw = $this->readFile($this->Vault . 'verification.yml')) === '') {
                $this->CIDRAM['SkipVerification'] = true;
                return;
            }
            $this->CIDRAM['VerificationData'] = [];
            $this->YAML->process($Raw, $this->CIDRAM['VerificationData']);
        }
        if (!isset($this->CIDRAM['VerificationData'][$From])) {
            return;
        }
        foreach ($this->CIDRAM['VerificationData'][$From] as $Name => $Values) {
            if (
                !is_array($Values) ||
                !isset($Values['Method'], $Values['Valid domains']) ||
                ($BypassFlags && !empty($Values['Bypass flag']) && !empty($this->CIDRAM[$Values['Bypass flag']]))
            ) {
                continue;
            }
            if (
                (!empty($Values['User Agent']) && strpos($this->BlockInfo['UALC'], $Values['User Agent']) !== false) ||
                (!empty($Values['User Agent Pattern']) && preg_match($Values['User Agent Pattern'], $this->BlockInfo['UALC']))
            ) {
                if (isset($this->CIDRAM['VPermissions'][$Name . ':Verify'])) {
                    $this->{$Values['Method']}($Values['Valid domains'], $Name, $Values);
                } elseif (isset($this->CIDRAM['VPermissions'][$Name . ':BlockNonVerified'])) {
                    $this->trigger(true, sprintf($this->L10N->getString('Short.Unverified_UA'), $Name));
                    $this->addProfileEntry('Blocked Non-Verified');
                }
            }
        }
    }

    /**
     * Add inspection entry.
     *
     * @param string $Name The name of the rule.
     * @param string $Condition The condition.
     * @param string $State The state of the match.
     * @return void
     */
    private function addInspectionEntry(string $Name, string $Condition, string $State): void
    {
        if (!isset($this->BlockInfo['Inspection'])) {
            return;
        }
        if ($this->BlockInfo['Inspection'] === '') {
            $this->BlockInfo['Inspection'] = '↓';
        }
        $Pair = $this->L10N->getString('pair_separator') ?: ': ';
        $this->BlockInfo['Inspection'] .= "\n- " . $Name . ' – ' . $Condition . $Pair . $State;
    }

    /**
     * Conflict response (as method to avoid duplicated code).
     *
     * @param string $Source Cited in the block reason.
     * @return void
     */
    private function conflictResponse(string $Source): void
    {
        if ($this->Configuration['signatures']['conflict_response'] === 429) {
            $Signature = 'RL';
            $this->BlockInfo['ReasonMessage'] = sprintf($this->ClientL10N->getString('ReasonMessage.RL') ?: $this->L10N->getString('ReasonMessage.RL'), sprintf($this->L10N->getPlural(3, '%s seconds'), $this->NumberFormatter->format(3)));
        } else {
            $Signature = 'Conflict';
            $this->BlockInfo['ReasonMessage'] = $this->ClientL10N->getString('ReasonMessage.Conflict') ?: $this->L10N->getString('ReasonMessage.Conflict');
        }
        if (!empty($this->BlockInfo['WhyReason'])) {
            $this->BlockInfo['WhyReason'] .= ', ';
        }
        $this->BlockInfo['WhyReason'] .= $this->L10N->getString('Short.' . $Signature) . ' (' . $Source . ')';
        if (!empty($this->BlockInfo['Signatures'])) {
            $this->BlockInfo['Signatures'] .= ', ';
        }
        $this->BlockInfo['Signatures'] .= 'Conflict';
        $this->BlockInfo['SignatureCount']++;
        $this->enactOptions('', ['ForciblyDisableReCAPTCHA' => true, 'ForciblyDisableHCaptcha' => true]);
        $this->CIDRAM['Other Status'] = $this->getStatusHTTP($this->Configuration['signatures']['conflict_response']);
        $this->CIDRAM['Other Status Code'] = $this->Configuration['signatures']['conflict_response'];
        if (isset($this->Shorthand[$Signature . ':Suppress'])) {
            $this->CIDRAM['Suppress output template'] = true;
        }
    }
}
