Subversion Repositories web.active

Rev

Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Pages Path Finder
 * 
 * #pw-summary Enables finding pages by path, optionally with URL segments, pagination numbers, language prefixes, etc.
 * #pw-body = 
 * This is built for use by the PagesRequest class and ProcessPageView module, but can also be useful from the public API.
 * The most useful method is the `get()` method which returns a verbose array of information about the given path. 
 * Methods in this class should be acessed from `$pages->pathFinder()`, i.e. 
 * ~~~~~
 * $result = $pages->pathFinder()->get('/en/foo/bar/page3');
 * ~~~~~
 * Note that PagesPathFinder does not perform any access control checks, so if using this class then validate access 
 * afterwards when appropriate.
 * #pw-body
 * 
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 * 
 * @todo:
 * Determine how to handle case where this class is called before 
 * LanguageSupport module has been loaded (for multi-language page names)
 *
 */

class PagesPathFinder extends Wire {

    /**
     * @var Pages
     * 
     */
    protected $pages;

    /**
     * Default options for the get() method
     * 
     * @var array
     * 
     * - useLanguages: Allows for selection of multi-language path paths (requires LanguageSupportPageNames module)
     * - usePathPaths: Allow use of PagePaths module to attempt to find a shortcut?
     * - useGlobalUnique: Allow support of shortcut for globally unique page names?
     * - useExcludeRoots: Exclude paths that do not have a known root segment, improves performance when lots of 404s. 
     * - useHistory: Allow detecting of previous page paths via the PagePathHistory module?
     * - verbose: Return more verbose array that includes details on each path segment?
     * 
     */
    protected $defaults = array(
        'useLanguages' => true,
        'usePagePaths' => true,
        'useGlobalUnique' => true,
        'useExcludeRoot' => false,
        'useHistory' => true,
        'verbose' => true,
        'test' => false,
    );

    /**
     * @var array
     * 
     */
    protected $options = array();

    /**
     * @var bool
     * 
     */
    protected $verbose = true;

    /**
     * @var array
     * 
     */
    protected $methods = array();

    /**
     * @var array
     * 
     */
    protected $result = array();

    /**
     * @var Template|null
     * 
     */
    protected $template = null;

    /**
     * @var bool|null
     * 
     */
    protected $admin = null;

    /**
     * @var array|null
     * 
     */
    protected $useLanguages = null;

    /**
     * URL part types (for reference)
     * 
     * @var array
     * 
     */
    protected $partTypes = array(
        'pageName', // references a page name directly
        'urlSegment', // part of urlSegmentStr after page path
        'pageNum', // pagination number segment
        'language', // language segment prefix
    );

    /**
     * Construct
     *
     * @param Pages $pages
     * 
     */
    public function __construct(Pages $pages) {
        $this->pages = $pages;
        parent::__construct();
    }

    /**
     * Init for new get() 
     * 
     * @param string $path
     * @param array $options
     * 
     */
    protected function init($path, array $options) {
        
        $this->options = array_merge($this->defaults, $options);
        $this->verbose = $this->options['verbose'];
        $this->methods = array();
        $this->useLanguages = $this->options['useLanguages'] ? $this->languages(true) : array();
        $this->result = $this->getBlankResult(array('request' => $path));
        $this->template = null;
        $this->admin = null;
        
        if(empty($this->pageNameCharset)) {
            $this->pageNameCharset = $this->wire()->config->pageNameCharset;
        }
    }
    
    /**
     * Get verbose array of info about a given page path
     *
     * This method accepts a page path (not URL) with optional language segment
     * prefix, additional URL segments and/or pagination number. It translates
     * the given path to information about what page it matches, what type of
     * request it would result in.
     *
     * If the `response` property in the return value is 301 or 302, then the
     * `redirect` property will be populated with a recommend redirect path.
     * 
     * Please access this method from `$pages->pathFinder()->get('…');`
     *
     * Below is an example when given a `$path` argument of `/en/foo/bar/page3` 
     * on a site that has default language homepage segment of `en`, a page living 
     * at `/foo/` that accepts URL segment `bar` and has pagination enabled;
     * ~~~~~
     * $array = $pages->pathFinder()->get('/en/foo/bar/page3'); 
     * ~~~~~
     * ~~~~~
     * [
     *   'request' => '/en/foo/bar/page3',
     *   'response' => 200, // one of 200, 301, 302, 404, 414
     *   'type' => 'ok', // response type name
     *   'errors' => [], // array of error messages indexed by error name
     *   'redirect` => '/redirect/path/', // suggested path to redirect to or blank
     *   'page' => [
     *      'id' => 123, // ID of the page that was found
     *      'parent_id' => 456,
     *      'templates_id' => 12,
     *      'status' => 1,
     *      'name' => 'foo',
     *   ],
     *   'language' => [
     *      'name' => 'default', // name of language detected
     *      'segment' => 'en', // segment prefix in path (if any)
     *      'status' => 1, // language status where 1 is on, 0 is off
     *    ],
     *   'parts' => [ // all the parts of the path identified
     *     [
     *       'type' => 'language',
     *       'value' => 'en',
     *       'language' => 'default'
     *     ],
     *     [
     *       'type' => 'pageName',
     *       'value' => 'foo',
     *       'language' => 'default'
     *     ],
     *     [
     *       'type' => 'urlSegment',
     *       'value' => 'bar',
     *       'language' => ''
     *     ],
     *     [
     *       'type' => 'pageNum',
     *       'value' => 'page3',
     *       'language' => 'default'
     *     ],
     *   ],
     *   'urlSegments' => [ // URL segments identified in order
     *      'bar',
     *   ],
     *   'urlSegmentStr' => 'bar', // URL segment string
     *   'pageNum' => 3, // requested pagination number
     *   'pageNumPrefix' => 'page', // prefix used in pagination number
     *   'scheme' => 'https', // blank if no scheme required, 'https' or 'http' if one of those is required
     *   'method' => 'pagesRow', // method(s) that were used to find the page
     * ]
     * ~~~~~
     *
     * @param string $path Page path optionally including URL segments, language prefix, pagination number
     * @param array $options
     *  - `useLanguages` (bool): Allow use of multi-language page names? (default=true)
     *     Requires LanguageSupportPageNames module installed.
     *  - `useHistory` (bool): Allow use historical path names? (default=true)
     *     Requires PagePathHistory module installed.
     *  - `verbose` (bool): Return verbose array of information? (default=true)
     *     If false, some optional information will be omitted in return value.
     * @return array
     * @see PagesPathFinder::getPage()
     *
     */
    public function get($path, array $options = array()) {
        
        $this->init($path, $options);
        
        // see if we can take a shortcut
        if($this->getShortcut($path)) return $this->result;
        
        // convert path to array of parts (page names)
        $parts = $this->getPathParts($path);
        
        // find a row matching the path/parts in the pages table
        $row = $this->getPagesRow($parts);
        $path = $this->applyPagesRow($parts, $row);
    
        // if we didn’t match row or matched with URL segments, see if history match available
        if($this->options['useHistory']) {
            if(!$row || ($this->result['page']['id'] === 1 && count($this->result['urlSegments']))) {
                if($this->getPathHistory($this->result['request'])) return $this->result;
            }
        }

        return $this->finishResult($path);
    }

    /**
     * Given a path, get a Page object or NullPage if not found
     * 
     * This method is like the `get()` method except that it returns a `Page`
     * object rather than an array. It sets a `_pagePathFinder` property to the 
     * returned Page, which is an associative array containing the same result 
     * array returned by the `get()` method.
     * 
     * Please access this method from `$pages->pathFinder()->getPage('…');`
     * 
     * @param string $path
     * @param array $options
     * @return NullPage|Page
     * @see PagesPathFinder::get()
     * 
     */
    public function getPage($path, array $options = array()) {
        if(!isset($options['verbose'])) $options['verbose'] = false;
        $result = $this->get($path, $options);
        if($result['response'] >= 400) {
            $page = $this->pages->newNullPage();
        } else {
            $page = $this->pages->getOneById($result['page']['id'], array(
                'template' => $this->getResultTemplate(),
                'parent_id' => $result['page']['parent_id'],
            ));
        }
        $page->setQuietly('_pagePathFinder', $result);
        return $page;
    }
    
    
    /**
     * Find a row for given $parts in pages table
     * 
     * @param array $parts
     * @return array|null
     * 
     */
    protected function getPagesRow(array $parts) {
    
        $database = $this->wire()->database;
        
        $selects = array();
        $binds = array();
        $joins = array();
        $wheres = array();

        $lastTable = 'pages';
        
        // build the query to match each part
        foreach($parts as $n => $name) {
            
            $name = $this->pageNameToAscii($name);
            $key = "p$n";
            $table = $n ? $key : 'pages';

            $selects[] = "$table.id AS {$key}_id";
            $selects[] = "$table.parent_id AS {$key}_parent_id";
            $selects[] = "$table.templates_id AS {$key}_templates_id";
            $selects[] = "$table.status AS {$key}_status";
            $selects[] = "$table.name AS {$key}_name";

            $bindKey = "{$key}_name";
            $binds[$bindKey] = $name;

            $whereNames = array("$table.name=:$bindKey");

            foreach($this->useLanguages as $lang) {
                /** @var Language $lang */
                if($lang->isDefault()) continue;
                $whereNames[] = "$table.name$lang->id=:$bindKey";
                $selects[] = "$table.name$lang->id AS {$key}_name$lang->id";
                $selects[] = "$table.status$lang->id AS {$key}_status$lang->id";
            }

            $whereNames = implode(' OR ', $whereNames);

            if($n) {
                $joins[] = "LEFT JOIN pages AS $table ON $table.parent_id=$lastTable.id AND ($whereNames)";
            } else {
                $where = "($table.parent_id=1 AND ($whereNames))";
                $wheres[] = $where;
            }

            $lastTable = $table;
        }
        
        if(!count($selects)) {
            $this->addResultNote('No selects from pagesRow');
            return null; 
        }

        $selects = implode(', ', $selects);
        $joins = implode(" \n", $joins);
        $wheres = implode(" AND ", $wheres);
        $sql = "SELECT $selects \nFROM pages \n$joins \nWHERE $wheres";
        $query = $database->prepare($sql);

        foreach($binds as $bindKey => $bindValue) {
            $query->bindValue(":$bindKey", $bindValue);
        }

        $query->execute();
        $rowCount = (int) $query->rowCount();
        $row = $query->fetch(\PDO::FETCH_ASSOC);
        $query->closeCursor();

        // multiple matches error (not likely)
        if($rowCount > 1) {
            $row = null;
            $this->addMethod('pagesRow', false, 'Multiple matches');
        } else if($rowCount) {
            $this->addMethod('pagesRow', true, "OK [id:$row[p0_id]]");
        } else {
            $this->addMethod('pagesRow', false, "No rows");
        }

        return $row;
    }

    /**
     * Apply a found pages table row to the $result and return corresponding path
     * 
     * @param array $parts
     * @param array|null $row
     * @return string Path string
     * 
     */
    protected function applyPagesRow(array $parts, $row) {
        
        $maxUrlSegmentLength = $this->wire()->config->maxUrlSegmentLength;
        $result = &$this->result;
        
        // array of [language name] => [ 'a', 'b', 'c' ] (from /a/b/c/)
        $namesByLanguage = array('default' => array());
        $statusByLanguage = array();
        
        // determine which parts matched and which didn’t
        foreach($parts as $n => $name) {

            $key = "p$n";
            $id = $row ? (int) $row["{$key}_id"] : 0;

            if(!$id) {
                // if it didn’t resolve to DB page name then it is a URL segment
                if(strlen($name) > $maxUrlSegmentLength) $name = substr($name, 0, $maxUrlSegmentLength);
                $result['urlSegments'][] = $name;
                if($this->verbose) {
                    $result['parts'][] = array(
                        'type' => 'urlSegment',
                        'value' => $name,
                        'language' => ''
                    );
                }
                continue;
            }

            $names[] = $name;
            $nameDefault = $this->pageNameToUTF8($row["{$key}_name"]);
            $namesByLanguage['default'][] = $nameDefault;

            // this is intentionally re-populated on each iteration 
            $result['page'] = array(
                'id' => $id,
                'parent_id' => (int) $row["{$key}_parent_id"],
                'templates_id' => (int) $row["{$key}_templates_id"],
                'status' => (int) $row["{$key}_status"],
                'name' => $nameDefault, 
            );

            if($this->verbose && $nameDefault === $name) {
                $result['parts'][] = array(
                    'type' => 'pageName',
                    'value' => $name,
                    'language' => 'default'
                );
            }

            foreach($this->useLanguages as $language) {
                /** @var Language $language */
                if($language->isDefault()) continue;
                $nameLanguage = $this->pageNameToUTF8($row["{$key}_name$language->id"]);
                $statusLanguage = (int) $row["{$key}_status$language->id"];
                $statusByLanguage[$language->name] = $statusLanguage;
                if($nameLanguage != $nameDefault && $nameLanguage === $name) {
                    if($this->verbose) {
                        $result['parts'][] = array(
                            'type' => 'pageName',
                            'value' => $nameLanguage,
                            'language' => $language->name
                        );
                    }
                    // if there is a part in a non-default language, identify that as the
                    // intended language for the entire path
                    if(empty($result['language']['name'])) $this->setResultLanguage($language);
                }
                if(!isset($namesByLanguage[$language->name])) $namesByLanguage[$language->name] = array();
                $namesByLanguage[$language->name][] = strlen("$nameLanguage") ? $nameLanguage : $nameDefault;
            }
        }
        
        // identify if a pageNum is present, must be applied before creation of urlSegmentStr and path
        $this->applyResultPageNum($parts);

        $langName = empty($result['language']['name']) ? 'default' : $result['language']['name'];
        if(!isset($namesByLanguage[$langName])) $langName = 'default';
        
        $path = '/' . implode('/', $namesByLanguage[$langName]);

        if($langName === 'default') {
            $result['page']['path'] = $path;
        } else {
            $result['page']['path'] = '/' . implode('/', $namesByLanguage['default']);
        }

        if(count($this->useLanguages)) {
            $langStatus = ($langName === 'default' ? 1 : $statusByLanguage[$langName]);
            $this->setResultLanguageStatus($langStatus);
        }
        
        return $path;
    }

    /**
     * Prepare $path and convert to array of $parts
     * 
     * If language segment detected then remove it and populate language to result
     * 
     * @param string $path
     * @return array|bool
     * 
     */
    protected function getPathParts($path) {

        $result = &$this->result;
        $config = $this->wire()->config;
        $sanitizer = $this->wire()->sanitizer;
        $maxDepth = $config->maxUrlDepth;
        $maxUrlSegmentLength = $config->maxUrlSegmentLength;
        $maxPartLength = $maxUrlSegmentLength > 128 ? $maxUrlSegmentLength : 128;
        $maxPathLength = ($maxPartLength * $maxUrlSegmentLength) + $maxDepth;
        $badNames = array();
        $path = trim($path, '/');
        $lastPart = '';
        
        if($this->strlen($path) > $maxPathLength) {
            $result['response'] = 414; // 414=URI too long
            $this->addResultError('pathLengthMAX', "Path length exceeds max allowed $maxPathLength");
            $path = substr($path, 0, $maxPathLength);
        }
        
        $parts = explode('/', trim($path, '/'));

        if(count($parts) > $maxDepth) {
            $parts = array_slice($parts, 0, $maxDepth);
            $result['response'] = 414;
            $this->addResultError('pathDepthMAX', 'Path depth exceeds config.maxUrlDepth');
        } else if($path === '/' || $path === '' || !count($parts)) {
            return array();
        }
        
        foreach($parts as $n => $name) {
            $lastPart = $name;
            if(ctype_alnum($name)) continue;
            $namePrev = $name;
            $name = $sanitizer->pageNameUTF8($name);
            $parts[$n] = $name;
            if($namePrev !== $name) $badNames[$n] = $namePrev;
        }

        if(stripos($lastPart, 'index.') === 0 && preg_match('/^index\.(php\d?|s?html?)$/i', $lastPart)) {
            array_pop($parts); // removing last part will force a 301
            $this->addResultError('indexFile', 'Path had index file');
        }
    
        if($result['response'] < 400 && count($badNames)) {
            $result['response'] = 400; // 400=Bad request
            $this->addResultError('pathBAD', 'Path contains invalid character(s)');
        }
        
        // identify language from parts and populate to result
        $this->getPathPartsLanguage($parts);

        return $parts;
    }

    /**
     * Get language that path is in and apply it to result
     * 
     * @param array $parts
     * @return Language|null
     * 
     */
    protected function getPathPartsLanguage(array &$parts) {

        if(!count($this->useLanguages) || !count($parts)) return null;
    
        $languages = $this->languages(); /** @var Languages|array $languages */
        
        if(!count($languages)) return null;
    
        $firstPart = reset($parts);
        $languageKey = array_search($firstPart, $this->languageSegments());
        
        if($languageKey === false) {
            // if no language segment present then do not identify language yet
            // allows for page /es/bici/ to still match /bici/ and redirect to /es/bici/
            return null;
            // Comment above and uncomment below to disable language detection without language prefix
            // $language = $languages->getDefault();
            // $segment = $this->languageSegment('default');
        } else {
            $segment = array_shift($parts);
            $language = $languages->get($languageKey);
        }
        
        if(!$language || !$language->id) return null;

        $this->addResultNote("Detected language '$language->name' from first segment '$segment'"); 
        $this->setResultLanguage($language, $segment);
        
        if($this->verbose && $languageKey !== false) {
            $this->result['parts'][] = array(
                'type' => 'language',
                'value' => $segment,
                'language' => $language->name
            );
        }

        // reduce to just applicable language to limit name columns
        // searched for by getPagesRow() method
        if($language) $this->useLanguages = array($language);
        
        return $language;
    }


    /*** RESULT *********************************************************************************/

    /**
     * Build blank result/return value array
     *
     * @param array $result Optionally return blank result merged with this one
     * @return array
     *
     */
    protected function getBlankResult(array $result = array()) {

        $blankResult = array(
            'request' => '',
            'response' => 0,
            'type' => '',
            'redirect' => '',
            'errors' => array(),
            'page' => array(
                'id' => 0,
                'parent_id' => 0,
                'templates_id' => 0,
                'status' => 0,
                'path' => '',
            ),
            'language' => array(
                'name' => '', // intentionally blank
                'segment' => '',
                'status' => -1, // -1=not yet set
            ),
            'parts' => array(),
            'urlSegments' => array(),
            'urlSegmentStr' => '',
            'pageNum' => 1,
            'pageNumPrefix' => '',
            'pathAdd' => '', // valid URL segments, page numbers, trailing slash, etc.
            'scheme' => '',
            'methods' => array(),
            'notes' => array(),
        );

        if(empty($result)) return $blankResult;

        $result = array_merge($blankResult, $result);

        return $result;
    }

    /**
     * Update paths for template info like urlSegments and pageNum and populate urls property
     *
     * @param string $path
     * @return bool|string
     *
     */
    protected function applyResultTemplate($path) {

        $config = $this->wire()->config;
        $fail = false;
        $result = &$this->result;

        if(empty($result['page']['templates_id']) && $this->isHomePath($path)) {
            $this->applyResultHome();
        }

        $template = $this->getResultTemplate();
        $slashUrls = $template ? (int) $template->slashUrls : 0;
        $useTrailingSlash = $slashUrls ? 1 : -1; // 1=yes, 0=either, -1=no
        $hadTrailingSlash = substr($result['request'], -1) === '/';
        $https = $template ? (int) $template->https : 0;
        $appendPath = '';

        // populate urlSegmentStr property if applicable
        if(empty($result['urlSegmentStr']) && !empty($result['urlSegments'])) {
            $urlSegments = $result['urlSegments'];
            $result['urlSegmentStr'] = count($urlSegments) ? implode('/', $urlSegments) : '';
        }

        // if URL segments are present validate them
        if(strlen($result['urlSegmentStr'])) {
            if($template && ($template->urlSegments || $template->name === 'admin')) {
                if($template->isValidUrlSegmentStr($result['urlSegmentStr'])) {
                    $appendPath .= "/$result[urlSegmentStr]";
                    if($result['pageNum'] < 2) $useTrailingSlash = (int) $template->slashUrlSegments;
                } else {
                    // ERROR: URL segments did not validate
                    $this->addResultError('urlSegmentsBAD', "Invalid urlSegments for template $template");
                    $fail = true;
                }
            } else {
                // template does not allow URL segments
                if($template) {
                    $this->addResultError('urlSegmentsOFF', "urlSegments disabled for template $template");
                }
                $fail = true;
            }
        }

        // if a pageNum is present validate it
        if($result['pageNum'] > 1) {
            if($template && $template->allowPageNum) {
                $maxPageNum = $this->wire()->config->maxPageNum;
                if($maxPageNum && $result['pageNum'] > $maxPageNum && $template->name != 'admin') {
                    $this->addResultError('pageNumBAD', "pageNum exceeds config.maxPageNum $maxPageNum");
                    $fail = true;
                }
                $segment = $this->pageNumUrlSegment($result['pageNum'], $result['language']['name']);
                if(strlen($segment)) {
                    $appendPath .= "/$segment";
                }
                $useTrailingSlash = (int) $template->slashPageNum;
            } else {
                // template does not allow page numbers
                $this->addResultError('pageNumOFF', "pageNum disabled for template $template");
                $fail = true;
            }
        }

        // determine whether path should end with a trailing slash or not
        if($useTrailingSlash > 0) {
            // trailing slash required
            $appendPath .= '/';
            if(!$hadTrailingSlash) $this->addResultNote('Enforced trailing slash');
        } else if($useTrailingSlash < 0) {
            // trailing slash disallowed
            if($hadTrailingSlash) $this->addResultNote('Enforced NO trailing slash');
        } else if($hadTrailingSlash) {
            // either acceptable, add slash if request had it
            $appendPath .= '/';
        }

        $_path = $path;
        if(strlen($appendPath)) $path = rtrim($path, '/') . $appendPath;
        if($fail || $_path !== $path) $result['redirect'] = '/' . ltrim($path, '/');
        
        $result['pathAdd'] = $appendPath;

        // determine if page requires specific https vs. http scheme
        if($https > 0 && !$config->noHTTPS) {
            // https required
            $result['scheme'] = 'https';
        } else if($https < 0) {
            // http required (https disallowed)
            $result['scheme'] = 'http';
        }

        return !$fail;
    }

    /**
     * Apply result for homepage match
     *
     */
    protected function applyResultHome() {
        $config = $this->wire()->config;
        $home = $this->getHomepage();
        $this->template = $home->template;
        $this->result['page'] = array_merge($this->result['page'], array(
            'id' => $config->rootPageID,
            'templates_id' => $this->template->id,
            'parent_id' => 0,
            'status' => $home->status
        ));
        $this->addMethod('resultHome', true);
    }

    /**
     * Identify and populate language information in result
     *
     * @param string $path
     * @return string $path Path is updated as needed
     *
     */
    protected function applyResultLanguage($path) {
        
        if(!count($this->useLanguages)) return $path;

        $result = &$this->result;

        /*
        if(empty($result['language']['name']) && $path != '/') {
            // @todo why?
            return $path;
        }
        */
    
        // admin does not use LanguageSupportPageNames
        if($this->isResultInAdmin()) {
            return $this->updatePathForLanguage($path, 'default');
        }
        
        // if(empty($result['language']['name'])) $result['language']['name'] = 'default';
        
        // if there were any non-default language segments, let that dictate the language
        if(empty($result['language']['segment'])) {
            $useLangName = 'default';
            foreach($result['parts'] as $key => $part) {
                $langName = $part['language'];
                if(empty($langName) || $langName === 'default') continue;
                $useLangName = $langName;
                break;
            }
            $segment = $this->languageSegment($useLangName);
            if($segment) $result['language']['segment'] = $segment;
            $result['language']['name'] = $useLangName;
        }
        
        $langName = $result['language']['name'];

        // prepend the redirect path with the language segment
        if(!empty($langName)) {
            $updatePath = $this->updatePathForLanguage($path);
            $redirect = &$result['redirect']; 
            if(empty($redirect) && $path != $updatePath) $redirect = $updatePath;
            if(!empty($redirect)) $redirect = $this->updatePathForLanguage($redirect);
            $path = $updatePath;
        }
    
        // determine language status if not yet known (likely only needed during shortcuts)
        if($result['language']['status'] < 0 && !empty($langName)) {
            if($langName === 'default') {
                $status = 1;
            } else if($result['page']['id']) {
                $status = $this->getPageLanguageStatus($result['page']['id'], $this->languageId($langName)); 
            } else {
                $status = -1;
            }
            $this->setResultLanguageStatus($status);
        }

        return $path;
    }

    /**
     * Identify and populate pagination number from $result['urlSegments']
     *
     * @param array $parts
     *
     */
    protected function applyResultPageNum(array &$parts) {

        $result = &$this->result;
        $urlSegments = $result['urlSegments'];
        if(!count($urlSegments)) return;

        $lastSegment = end($urlSegments);
        if(!ctype_digit(substr($lastSegment, -1))) return;

        foreach($this->pageNumUrlPrefixes() as $languageName => $pageNumUrlPrefix) {
            if(strpos($lastSegment, $pageNumUrlPrefix) !== 0) continue;
            if(!preg_match('!^' . $pageNumUrlPrefix . '(\d+)$!i', $lastSegment, $matches)) continue;
            $segment = $matches[0];
            $pageNum = (int) $matches[1];
            $result['pageNum'] = $pageNum;
            $result['pageNumPrefix'] = $pageNumUrlPrefix;
            array_pop($urlSegments);
            array_pop($parts);
            if($this->verbose) {
                array_pop($result['parts']);
                $result['parts'][] = array(
                    'type' => 'pageNum',
                    'value' => $segment,
                    'language' => (is_string($languageName) ? $languageName : 'default'),
                );
            }
            break;
        }

        $result['urlSegments'] = $urlSegments;
    }

    /**
     * Finish result/return value
     *
     * @param string|bool $path Path string or boolean false when 404
     * @return array
     *
     */
    protected function finishResult($path) {

        $result = &$this->result;
        $types = $this->pages->request()->getResponseCodeNames();

        if($path !== false) $path = $this->applyResultLanguage($path);
        if($path !== false) $path = $this->applyResultTemplate($path);
        if($path === false) $result['response'] = 404;

        $response = &$result['response'];
        $language = &$result['language'];
        $errors = &$result['errors'];
        
        if($response === 404) {
            // page not found
            if(empty($result['errors'])) $result['errors']['pageNotFound'] = "Page not found";

        } else if($response == 414) {
            // path too long

        } else if($result['request'] === $result['redirect'] || empty($result['redirect'])) {
            // blank redirect values mean no redirect is suggested
            $response = 200;
            $result['redirect'] = '';

        } else if($response === 301 || $response === 302) {
            // redirect identified

        } else {
            // redirect suggested
            $response = 301;
        }
        
        if($result['pageNum'] > 1 && ($response === 301 || $response === 302)) {
            // redirect where pageNum is greater than 1
            $response = $this->finishResultRedirectPageNum($response, $result);
        }

        if(empty($language['name'])) {
            // set language property (likely for non-multi-language install)
            $language['name'] = 'default';
            $language['status'] = 1;
            
        } else if($language['name'] != 'default' && !$language['status'] && $result['page']['id']) {
            // page found but not published in language (needs later decision)
            $response = 300; // 300 Multiple Choice
            $errors['languageOFF'] = "Page not active in request language ($language[name])";
            $result['redirect'] = $this->pages->getPath($result['page']['id']); 
            if($result['pathAdd']) $result['redirect'] = rtrim($result['redirect'], '/') . $result['pathAdd'];
        }
        
        if(empty($result['type']) && isset($types[$response])) {
            if($result['response'] === 404 && !empty($result['redirect'])) {
                // when page found but path not use the 400 response type name w/404
                $result['type'] = $types[400];
            } else {
                $result['type'] = $types[$response];
            }
        }

        $result['methods'] = $this->methods;

        if(!$this->verbose) unset($result['parts'], $result['methods']);

        if(empty($errors)) {
            // force errors placeholder to end if there aren’t any
            unset($result['errors']);
            $result['errors'] = array();
        }
        
        if($this->options['test']) {
            // add 'test' to the result that should match regardless of options (except useLanguages option)
            $redirect = ($result['response'] >= 300 && $result['response'] < 400 ? $result['redirect'] : '');
            $pageNumStr = ($result['pageNum'] > 1 ? "/$result[pageNumPrefix]$result[pageNum]" : '');
            $result['test'] = array(
                'response=' . $result['response'],
                'page=' . $result['page']['id'], 
                'redirect=' . $redirect,
                'language=' . $result['language']['name'] . '[' . $result['language']['status'] . ']',
                'uss=' . $result['urlSegmentStr'] . $pageNumStr,
            );
            $result['test'] = implode(', ', $result['test']); 
        }
        
        return $result;
    }

    /*** SHORTCUTS ******************************************************************************/
    
    /**
     * Attempt to match path to page using shortcuts and return true if shortcut found
     *
     * @param string $path
     * @return bool Return true if shortcut found and result ready, false if not
     *
     */
    protected function getShortcut($path) {

        $found = false;
        // $slash = substr($path, -1) === '/' ? '/' : '';
        $path = trim($path, '/'); 
        
        // check for pagination segment, which we don’t want in our path here
        list($pageNum, $pageNumPrefix) = $this->getShortcutPageNum($path);
        
        if(strpos($path, '/') === false) {
            // single directory off root
            $found = $this->getShortcutRoot($path);
        }
    
        if(!$found) {
            if($this->getShortcutExcludeRoot($path)) {
                $found = true;
            } else if($this->getShortcutPagePaths($path)) {
                $found = true;
            }
        }

        if(!$found) return false;

        $this->result['pageNum'] = $pageNum;
        $this->result['pageNumPrefix'] = $pageNumPrefix;
    
        $this->result = $this->finishResult($path);

        return true;
    }

    /**
     * @param string $path
     * @return bool
     * 
     */
    protected function getShortcutRoot($path) {
        
        if($path === '') {
            $this->setResultLanguage('default');
            $this->setResultLanguageStatus(1);
            $this->applyResultHome();
            return true;
        }
        
        if(count($this->useLanguages)) {
            $languageId = $this->isLanguageSegment($path);
            if($languageId !== false) {
                $langName = $this->languageName($languageId);
                $langStatus = $langName === 'default' ? 1 : $this->getHomepage()->get("status$languageId");
                $this->setResultLanguage($langName, $path); 
                $this->setResultLanguageStatus($langStatus);
                $this->applyResultHome();
                return true;
            }
        }
            
        if($this->getShortcutGlobalUnique($path)) {
            return true;
        }
        
        return false;
    }

    /**
     * Find out if we can early exit 404 based on the root segment
     * 
     * Unlike other shortcuts, this one is an exclusion shortcut: 
     * Returns false if the root segment matched and further analysis should take place.
     * Returns true if root segment is not in this site and 404 should be the result. 
     *
     * @param string $path
     * @return bool 
     *
     */
    protected function getShortcutExcludeRoot($path) {
        
        if(!$this->options['useExcludeRoot']) return false;
        if(!$this->options['usePagePaths']) return false;
        
        $module = $this->pagePathsModule();
        if(!$module) return false;
        
        $homepage = $this->pages->get((int) $this->wire()->config->rootPageID);
        
        // if root/home template allows URL segments then potentially anything
        // can match the root segment, so this shortcut is not worthwhile
        if($homepage->template->urlSegments) return false;
    
        $config = $this->wire()->config;
        $path = trim($path, '/');
        
        if(strpos($path, '/')) {
            list($segment,) = explode('/', $path, 2);
        } else {
            $segment = $path;
        }
        
        if(strlen($segment) <= $config->maxUrlSegmentLength) {

            if($module->isRootSegment($segment)) {
                $this->addMethod('excludeRoot.paths', true);
                return false; // root segment found
            } else {
                $this->addMethod('excludeRoot.paths', false);
            }

            if($this->options['useHistory']) {
                $module = $this->pagePathHistoryModule();
                if($module && $this->options['useHistory']) {
                    if($module->isRootSegment($segment)) {
                        $this->addMethod('excludeRoot.history', true);
                        return false; // root segment found
                    }
                    $this->addMethod('excludeRoot.history', false);
                }
            }
            $response = 404;
        } else {
            $response = 414;
        }
        
        // at this point we know given path does not have a valid root segment
        // and cannot possibly match any page so we can safely stop further 
        // processing and return a 404 not found result
        
        $this->result['response'] = $response; 
        $this->addMethod('excludeRoot', $response, 'Early exit for root segment 404');
        
        return true;
    }
        

    /**
     * Find a shortcut using the PagePaths module
     *
     * @param string $path
     * @return bool Returns true if found, false if not installed or not found
     *
     */
    protected function getShortcutPagePaths(&$path) {

        if(!$this->options['usePagePaths']) return false;
        $module = $this->pagePathsModule();
        if(!$module) return false;

        $result = &$this->result;
        $info = $module->getPageInfo($path);
        
        if(!$info) {
            $this->addMethod('pagePaths', false);
            return false;
        }

        $language = $this->language((int) $info['language_id']);
        
        $path = "/$info[path]";

        unset($info['language_id'], $info['path']);

        $result['page']['id'] = $info['id'];
        $result['page']['status'] = $info['status'];
        $result['page']['templates_id'] = $info['templates_id'];
        $result['page']['parent_id'] = $info['parent_id'];

        $result['response'] = 200;

        if($language && $language->id) {
            $status = $language->isDefault() ? $info['status'] : $info["status$language->id"];
            $result['language'] = array_merge($result['language'], array(
                'name' => $language->name,
                'status' => ($language->status < Page::statusUnpublished ? $status : 0),
                'segment' => $this->languageSegment($language)
            ));
        }
        
        $this->addMethod('pagePaths', true);

        return true;
    }

    /**
     * Attempt to match a page with status 'unique' or having parent_id=1
     * 
     * This method only proceeds if the path contains no slashes, meaning it is 1-level from root.
     *
     * @param string $path
     * @return bool
     *
     */
    protected function getShortcutGlobalUnique(&$path) {
        
        if(!$this->options['useGlobalUnique']) return false;

        $database = $this->wire()->database;
        $unique = Page::statusUnique;

        if(strpos($path, '/') !== false) return false;

        $name = $this->pageNameToAscii($path);
        $columns = array('id', 'parent_id', 'templates_id', 'status');
        $cols = implode(',', $columns);

        $sql = "SELECT $cols FROM pages WHERE name=:name AND (parent_id=1 OR (status & $unique))";
        $query = $database->prepare($sql);
        $query->bindValue(':name', $name);
        $query->execute();

        $row = $query->fetch(\PDO::FETCH_ASSOC);
        $query->closeCursor();

        if(!$row) {
            $this->addMethod('globalUnique', false);
            return false;
        }
        
        foreach($row as $k => $v) $row[$k] = (int) $v;

        $result = &$this->result;
        $result['page'] = array_merge($result['page'], $row);
        $result['response'] = 200;
        $result['language']['name'] = 'default';
        
        if($row['parent_id'] === 1) {
            $template = $this->wire()->templates->get($row['templates_id']); 
            $slashUrls = $template ? (int) $template->slashUrls : 0;
            $slash = ($slashUrls ? $slashUrls > 0 : substr($path, -1) === '/');
            $path = "/$path" . ($slash ? '/' : '');
        } else {
            // global unique, must redirect to its actual path
            $page = $this->pages->getOneById((int) $row['id'], array(
                'template' => (int) $row['templates_id'],
                'autojoin' => false,
            ));
            if(!$page->id) return false; // not likely
            $path = $page->path();
        }

        $result['redirect'] = $path;
        $this->addMethod('globalUnique', true);

        return true;
    }

    /**
     * Identify shortcut pagination info
     *
     * Returns found pagination number, or 1 if first pagination.
     * Extracts the pagination segment from the path.
     *
     * @param string $path
     * @return array of [ pageNum, pageNumPrefix ]
     *
     */
    protected function getShortcutPageNum(&$path) {

        if(!ctype_digit(substr($path, -1))) return array(1, '');

        $pos = strrpos($path, '/');
        $lastPart = $pos ? substr($path, $pos+1) : $path;
        $pageNumPrefix = '';
        $pageNum = 1;

        foreach($this->pageNumUrlPrefixes() as $prefix) {
            if(strpos($lastPart, $prefix) !== 0) continue;
            if(!preg_match('/^' . $prefix . '(\d+)$/', $lastPart, $matches)) continue;
            $pageNumPrefix = $prefix;
            $pageNum = (int) $matches[1];
            break;
        }

        if($pageNumPrefix) {
            $path = $pos ? substr($path, 0, $pos) : '';
            if($pageNum < 2) $pageNum = 1;
        }

        return array($pageNum, $pageNumPrefix);
    }

    /**
     * Attempt to match page path from PagePathHistory module
     *
     * @param string $path
     * @return bool
     *
     */
    protected function getPathHistory($path) {

        if(!$this->options['useHistory']) return false;
        
        $module = $this->pagePathHistoryModule();
        if(!$module) return false;

        $result = &$this->result;
        $info = $module->getPathInfo($path, array('allowUrlSegments' => true));

        // if no history found return false
        if(!$info['id']) {
            $this->addMethod('pathHistory', false);
            return false;
        }

        // get page found in history
        $page = $this->pages->getOneById((int) $info['id'], array(
            'template' => (int) $info['templates_id'],
            'parent_id' => $info['parent_id'],
            'autojoin' => false,
        ));

        if(!$page->id) {
            $this->addMethod('pathHistory', false, 'Found row but could not match to page');
            return false;
        }

        $path = $page->path;
        $languageName = $this->languageName($info['language_id']);
        $keys = array('id', 'language_id', 'templates_id', 'parent_id', 'status', 'name');

        foreach($keys as $key) {
            $result['page'][$key] = $info[$key];
        }

        $result['language']['name'] = $languageName;
        $result['response'] = 301;
        $result['redirect'] = $path;

        $urlSegmentStr = $info['urlSegmentStr'];

        if(strlen($urlSegmentStr)) {
            $result['urlSegments'] = explode('/', $info['urlSegmentStr']);
            $this->applyResultPageNum($result['parts']);
        }

        $result = $this->finishResult($path);
        $this->addMethod('pathHistory', true);

        return true;
    }


    /*** UTILITIES ******************************************************************************/
    
    /**
     * @var string
     *
     */
    protected $pageNameCharset = '';

    /**
     * Get page number segment with given pageNum and and in given language name
     *
     * @param int $pageNum
     * @param string $langName
     * @return string
     *
     */
    protected function pageNumUrlSegment($pageNum, $langName = 'default') {
        $pageNum = (int) $pageNum;
        if($pageNum < 2) return '';
        $a = $this->pageNumUrlPrefixes();
        $prefix = isset($a[$langName]) ? $a[$langName] : $a['default'];
        return $prefix . $pageNum;
    }

    /**
     * Get pageNum URL prefixes indexed by language name
     *
     * @return array
     *
     */
    protected function pageNumUrlPrefixes() {
        $config = $this->wire()->config;
        $a = $config->pageNumUrlPrefixes;
        if(!is_array($a)) $a = array();
        $default = $config->pageNumUrlPrefix;
        if(empty($default)) $default = 'page';
        if(empty($a['default'])) $a['default'] = $default;
        return $a;
    }

    /**
     * Does given path refer to homepage?
     * 
     * @param string $path
     * @return bool
     * 
     */
    protected function isHomePath($path) {
        $path = trim($path, '/');
        if($path === '') return true;
        $isHomePath = false;
        foreach($this->languageSegments() as $segment) {
            if($path !== $segment) continue;
            $isHomePath = true;
            break;
        }
        return $isHomePath; 
    }

    /**
     * Convert ascii page name to UTF-8
     *
     * @param string $name
     * @return string
     *
     */
    protected function pageNameToUTF8($name) {
        $name = (string) $name;
        if($this->pageNameCharset !== 'UTF8') return $name;
        if(strpos($name, 'xn-') !== 0) return $name;
        return $this->wire()->sanitizer->pageName($name, Sanitizer::toUTF8);
    }

    /**
     * Convert UTF-8 page name to ascii
     *
     * @param string $name
     * @return string
     *
     */
    protected function pageNameToAscii($name) {
        if($this->pageNameCharset !== 'UTF8') return $name;
        return $this->wire()->sanitizer->pageName($name, Sanitizer::toAscii);
    }

    /**
     * Get template used by page found in result or null if not yet known 
     * 
     * @return null|Template
     * 
     */
    protected function getResultTemplate() {
        if(!$this->template && !empty($this->result['page']['templates_id'])) {
            $this->template = $this->wire()->templates->get($this->result['page']['templates_id']); 
        }
        return $this->template;
    }

    /**
     * Is matched result in admin?
     * 
     * @return bool
     * 
     */
    protected function isResultInAdmin() {
        if($this->admin !== null) return $this->admin;
        $config = $this->wire()->config;
        if($this->result['page']['templates_id'] === 2) {
            $this->admin = true;
        } else if($this->result['page']['id'] === $config->adminRootPageID) {
            $this->admin = true;
        } else {
            $template = $this->getResultTemplate();
            if(!$template) {
                return false; // may need to detect later
            } if(in_array($template->name, $config->adminTemplates, true)) {
                $this->admin = true;
            } else if(in_array($template->name, array('user', 'role', 'permission', 'language'))) {
                $this->admin = true;
            } else {
                $this->admin = false;
            }
        }
        return $this->admin;
    }

    /**
     * Get string length, using mb_strlen() if available, strlen() if not
     *
     * @param string $str
     * @return int
     *
     */
    protected function strlen($str) {
        return function_exists('mb_strlen') ? mb_strlen($str) : strlen($str);
    }

    /**
     * Add method debug info (verbose mode)
     * 
     * @param string $name
     * @param int|bool $code
     * @param string $note
     * 
     */
    protected function addMethod($name, $code, $note = '') {
        if(!$this->verbose) return;
        if($code === true) $code = 200;
        if($code === false) $code = 404;
        if(empty($note)) {
            if($code === 200) $note = 'OK';
            if($code === 404) $note = 'Not found';
        }
        if($note) $code = "$code $note";
        $this->methods[] = "$name: $code";
    }

    /**
     * Get homepage
     * 
     * @return Page
     * 
     */
    protected function getHomepage() {
        return $this->pages->get((int) $this->wire()->config->rootPageID);
    }

    /**
     * Add named error message to result
     * 
     * @param string $name
     * @param string $message
     * 
     */
    protected function addResultError($name, $message) {
        if(!$this->verbose) return;
        $this->result['errors'][$name] = $message;
    }

    /**
     * Add note to result
     * 
     * @param string $message
     * 
     */
    protected function addResultNote($message) {
        if(!$this->verbose) return;
        $this->result['notes'][] = $message;
    }

    /**
     * Get default options
     * 
     * #pw-internal
     * 
     * @return array
     * 
     */
    public function getDefaultOptions() {
        return $this->defaults;
    }
    
    /*** MODULES **********************************************************************************/
    
    /**
     * @var PagePaths|null
     *
     */
    protected $pagePathsModule = null;

    /**
     * @var PagePathHistory|null
     *
     */
    protected $pagePathHistoryModule = null;

    /**
     * @var null|PagesPathFinderTests 
     * 
     */
    protected $tester = null;

    /**
     * Get optional PathPaths module instance if it is installed, false if not
     *
     * @return bool|PagePaths
     *
     */
    protected function pagePathsModule() {
        if($this->pagePathsModule !== null) return $this->pagePathsModule;
        $modules = $this->wire()->modules;
        if($modules->isInstalled('PagePaths')) {
            $this->pagePathsModule = $modules->getModule('PagePaths');
        } else {
            $this->pagePathsModule = false;
        }
        return $this->pagePathsModule;
    }

    /**
     * Get optional PagePathHistory module instance if it is installed, false if not
     * 
     * @return PagePathHistory|bool
     *
     */
    protected function pagePathHistoryModule() {
        if($this->pagePathHistoryModule !== null) return $this->pagePathHistoryModule;
        $modules = $this->wire()->modules;
        if($modules->isInstalled('PagePathHistory')) {
            $this->pagePathHistoryModule = $modules->getModule('PagePathHistory');
        } else {
            $this->pagePathHistoryModule = false;
        }
        return $this->pagePathHistoryModule;
    }

    /**
     * Get PagesPathFinderTests instance
     * 
     * #pw-internal
     * 
     * @return PagesPathFinderTests
     * 
     */
    public function tester() {
        if($this->tester) return $this->tester;
        $this->tester = new PagesPathFinderTests();
        $this->wire($this->tester);
        return $this->tester;
    }
    
    /*** LANGUAGES ******************************************************************************/
    
    /**
     * Cache for languageSegments() method
     *
     * @var array
     *
     */
    protected $languageSegments = array();

    /**
     * Language names indexed by id
     *
     * @var array
     *
     */
    protected $languageNames = array();

    /**
     * Set result language by name or ID
     * 
     * @param int|string|Language $language
     * @param string $segment
     * 
     */
    protected function setResultLanguage($language, $segment = '') {
        if(is_object($language)) {
            $name = $language->name;
        } else if(ctype_digit("$language")) {
            $id = (int) $language; 
            $name = $this->languageName($id); 
        } else {
            $name = $language; 
        }
        $this->result['language']['name'] = $name;
        if($segment !== '') $this->setResultLanguageSegment($segment);
    }

    /**
     * Set result language segment
     *
     * @param string $segment
     *
     */
    protected function setResultLanguageSegment($segment) {
        $this->result['language']['segment'] = $segment;
    }

    /**
     * Set result language status
     * 
     * @param int|bool $status
     * 
     */
    protected function setResultLanguageStatus($status) {
        $this->result['language']['status'] = (int) $status;
    }
    
    /**
     * Get value from page status column
     *
     * @param int $pageId
     * @param int $languageId
     * @return int
     *
     */
    protected function getPageLanguageStatus($pageId, $languageId) {
        $pageId = (int) $pageId;
        $languageId = (int) $languageId;
        $langName = $languageId ? $this->languageName($languageId) : 'default';
        $col = $langName === 'default' ? 'status' : "status$languageId";
        $page = $this->pages->cacher()->getCache((int) $pageId);
        if($page) return $page->get($col);
        $query = $this->wire()->database->prepare("SELECT `$col` FROM pages WHERE id=:id");
        $query->bindValue(':id', $pageId, \PDO::PARAM_INT);
        try {
            $query->execute();
            $status = (int) $query->fetchColumn();
            $query->closeCursor();
        } catch(\Exception $e) {
            $status = -1;
        }
        return $status;
    }


    /**
     * Return Languages if installed w/languageSupportPageNames module or blank array if not
     * 
     * @param bool $getArray Force return value as an array indexed by language name
     * @return Languages|array
     *
     */
    protected function languages($getArray = false) {
        if(is_array($this->useLanguages) && !count($this->useLanguages)) {
            return array();
        }
        $languages = $this->wire()->languages;
        if(!$languages) {
            $this->useLanguages = array();
            return array();
        }
        if(!$languages->hasPageNames()) {
            $this->useLanguages = array();
            return array();
        }
        if($getArray) {
            $a = array();
            foreach($languages as $language) {
                $a[$language->name] = $language;
            }
            $languages = $a;
        }
        return $languages;
    }

    /**
     * Given a value return corresponding language
     * 
     * @param string|int|Language $value
     * @return Language|null
     * 
     */
    protected function language($value) {
    
        $language = null;
        
        if($value instanceof Page) {
            if($value->className() === 'Language' || wireInstanceOf($value, 'Language')) {
                $language = $value;
            }
        } else {
            /** @var Languages|array $languages */
            $languages = $this->languages();
            if(!count($languages)) return null;

            $id = $this->languageId($value);
            $language = $id ? $languages->get($id) : null;
        }
        
        if(!$language || !$language->id) return null;
        
        return $language;
    }

    /**
     * Get homepage name segments used for each language, indexed by language id
     * 
     * #pw-internal
     *
     * @return array
     *
     */
    public function languageSegments() {

        // use cached value when available
        if(count($this->languageSegments)) return $this->languageSegments;

        $columns = array();
        $languages = $this->languages();
        
        if(!count($languages)) return array();

        foreach($languages as $language) {
            $name = $language->isDefault() ? "name" : "name$language->id";
            $columns[$name] = $language->id;
        }

        // see if homepage already loaded in cache
        $homepage = $this->pages->cacher()->getCache(1);

        // if homepage available, get segments from it
        if($homepage) {
            foreach($columns as $name => $languageId) {
                $value = $homepage->get($name);
                if($name === 'name' && $value === Pages::defaultRootName) $value = '';
                $this->languageSegments[$languageId] = $value;
            }
            
        } else {
            // if homepage not already loaded, pull segments directly from pages table
            $config = $this->wire()->config;
            $database = $this->wire()->database;

            $cols = implode(', ', array_keys($columns));
            $sql = "SELECT $cols FROM pages WHERE id=:id";

            $query = $database->prepare($sql);
            $query->bindValue(':id', (int) $config->rootPageID, \PDO::PARAM_INT);
            $query->execute();

            $row = $query->fetch(\PDO::FETCH_ASSOC);
            $query->closeCursor();

            foreach($row as $name => $value) {
                $languageId = $columns[$name];
                $value = $this->pageNameToUTF8($value);
                if($name === 'name' && $value === Pages::defaultRootName) $value = '';
                $this->languageSegments[$languageId] = $value;
            }
        }

        return $this->languageSegments;
    }

    /**
     * Is given segment a language segment? Returns language ID if yes, false if no
     * 
     * #pw-internal
     * 
     * @param string $segment
     * @return false|int
     * 
     */
    public function isLanguageSegment($segment) {
        $key = array_search($segment, $this->languageSegments()); 
        return $key;
    }

    /**
     * Return homepage name segment used by given language
     * 
     * @param string|int|Language $language
     * @return string
     * 
     */
    protected function languageSegment($language) {
        $id = $this->languageId($language);
        $segments = $this->languageSegments();
        return isset($segments[$id]) ? $segments[$id] : '';
    }
    
    /**
     * Return language identified by homepage name segment
     * 
     * @param string $segment
     * @param bool $getLanguageId
     * @return Language|null|int
     * 
     */
    protected function segmentLanguage($segment, $getLanguageId = false) {
        $segments = $this->languageSegments();
        $languageId = array_search($segment, $segments);    
        if($getLanguageId) return $languageId ? $languageId : 0;
        return $languageId ? $this->language($languageId) : null;
    }
    
    /**
     * Return name for given language id or object
     * 
     * @param int|string|Language $language
     * @return string
     * 
     */
    protected function languageName($language) {
        $names = $this->languageNames();
        $languageId = $this->languageId($language);
        if(isset($names[$languageId])) return $names[$languageId];
        return 'default';
    }
    
    /**
     * Return all language names indexed by language id
     * 
     * @return array
     * 
     */
    protected function languageNames() {
        if(empty($this->languageNames)) {
            foreach($this->languages() as $lang) {
                $this->languageNames[$lang->id] = $lang->name;
            }
        }
        return $this->languageNames;
    }

    /**
     * Return language id for given value (language name, name column or home segment)
     * 
     * @param int|string|Language $language
     * @return int
     * 
     */
    protected function languageId($language) {
        $language = (string) $language;
        if(ctype_digit("$language")) return (int) "$language"; // object or number string
        if($language === 'name' || $language === 'default') {
            // name property translates to default language
            $languages = $this->languages();
            return count($languages) ? $languages->getDefault()->id : 0;
        } else if(strpos($language, 'name') === 0 && ctype_digit(substr($language, 5))) {
            // i.e. name1234 where 1234 is language id
            $language = str_replace('name', '', $language);
        } else if(!ctype_digit($language)) {
            // likely a language name
            $langName = $language;
            $language = null;
            foreach($this->languages() as $lang) {
                if($lang->name === $langName) $language = $lang;
                if($language) break;
            }
            if(!$language) {
                // if not found by language name, try home language segments
                $languageId = 0;
                foreach($this->languageSegments() as $id => $segment) {
                    if($segment === $langName) $languageId = (int) $id;
                    if($languageId) break;
                }
                if($languageId) return $languageId;
            }
            $language = $language ? $language->id : 0;
        }
        return (int) $language;
    }

    /**
     * Update given path for result language and return it
     *
     * @param string $path
     * @param string $langName
     * @return string
     *
     */
    protected function updatePathForLanguage($path, $langName = '') {
        
        $result = &$this->result;   
        $config = $this->wire()->config;
        $template = $this->getResultTemplate();
        
        if($template && in_array($template->name, $config->adminTemplates)) {
            return $this->removeLanguageSegment($path);
        }
    
        if(empty($langName)) $langName = $result['language']['name'];
        if(empty($langName)) $langName = 'default';
        
        if($langName === 'default' && ($result['page']['id'] === 1 || $path === '/')) {
            $pageNames = $this->wire()->languages->pageNames();
            if(!$pageNames) return $path; 
            $useSegment = $pageNames->useHomeSegment;
        } else {
            $useSegment = true;
        }
            
        if($useSegment) {
            $path = $this->addLanguageSegment($path, $langName);
        } else {
            $path = $this->removeLanguageSegment($path);
        }
        
        return $path;
    }

    /**
     * Add language segment
     * 
     * @param string $path
     * @param string|Language|int $language
     * @return string
     * 
     */
    protected function addLanguageSegment($path, $language) {
        if(strpos($path, '/') !== 0) $path = "/$path";
        $segment = $this->languageSegment($language);
        if(!strlen($segment)) return $path;
        if($path === "/$segment" && $this->result['page']['id'] < 2) return $path;
        if(strpos($path, "/$segment/") === 0) return $path;
        return "/$segment$path";
    }

    /**
     * Remove any language segments present on given path
     * 
     * @param string $path
     * @return string
     * 
     */
    protected function removeLanguageSegment($path) {
        if(strpos($path, '/') !== 0) $path = "/$path";
        if($path === '/') return $path;
        $segments = $this->languageSegments();
        $segments[] = Pages::defaultRootName;
        foreach($segments as $segment) {
            if($segment === null || !strlen($segment)) continue;
            if($path !== "/$segment" && strpos($path, "/$segment/") !== 0) continue;
            list(,$path) = explode("/$segment", $path, 2); 
            if($path === '') $path = '/';
            break;
        }
        return $path;
    }
    
    
    /*** ADDITIONAL/HELPER LOGIC ****************************************************************/
    
    /**
     * Update result for cases where a redirect was determined that involved pagination
     *
     * Most of the logic here allows for the special case of admin URLs, which work with either
     * a custom pageNumUrlPrefix or the original/default one. This is a helper for the
     * finishResult() method. 
     *
     * @param int $response
     * @var array $result
     * @return int
     * @since 3.0.198
     *
     */
    protected function finishResultRedirectPageNum($response, &$result) {

        if($result['pageNum'] < 2) return $response;

        if(empty($result['page']['templates_id'])) return $response;
        if($result['page']['status'] >= Page::statusUnpublished) return $response;

        // The config[_pageNumUrlPrefix] property is set by LanguageSupportPageNames
        $pageNumUrlPrefix = $this->wire()->config->get('_pageNumUrlPrefix');
        if(empty($pageNumUrlPrefix)) $pageNumUrlPrefix = 'page';

        // if default/original pageNum prefix not in use then do nothing further
        if($result['pageNumPrefix'] !== $pageNumUrlPrefix) return $response;

        // if request is not for something in the admin then do nothing further
        $adminTemplate = $this->wire()->templates->get('admin');
        if(!$adminTemplate || $result['page']['templates_id'] != $adminTemplate->id) return $response;

        // request is for pagination within admin, where we allow either custom or original/default prefix
        $requestParts = explode('/', trim($result['request'], '/'));
        $redirectParts = explode('/', trim($result['redirect'], '/'));

        $requestPrefix = array_pop($requestParts);
        $redirectPrefix = array_pop($redirectParts);

        $requestPath = implode('/', $requestParts);
        $redirectPath = implode('/', $redirectParts);

        // if something other than pagination prefix differs then do nothing further
        if($requestPath != $redirectPath || $requestPrefix === $redirectPrefix) return $response;

        // only the pagination prefix differs, allow it when in admin
        $result['notes'][] = "Default pagination prefix '$pageNumUrlPrefix' allowed for admin template";
        $response = 200;

        return $response;
    }

}

/**
 * PagesPathFinder Tests
 * 
 * Usage:
 * ~~~~~
 * $tester = $pages->pathFinder()->tester();
 * $a = $tester->testPath('/path/to/page/'); 
 * $a = $tester->testPage(Page $page);
 * $a = $tester->testPages("has_parent!=2");
 * $a = $tester->testPages(PageArray $items); 
 * ~~~~~
 * 
 */
class PagesPathFinderTests extends Wire {

    /**
     * @return PagesPathFinder
     * 
     */
    public function pathFinder() {
        return $this->wire()->pages->pathFinder();
    }
    
    /**
     * @param string $path
     * @param int $expectResponse
     * @return array
     *
     */
    public function testPath($path, $expectResponse = 0) {
        $tests = array();
        $testResults = array();
        $results = array();
        $optionSets = array(
            'defaults' => $this->pathFinder()->getDefaultOptions(),
            'noPagePaths' => array('usePagePaths' => false),
            'noGlobalUnique' => array('useGlobalUnique' => false),
            'noHistory' => array('useHistory' => false),
            'excludeRoot' => array('useExcludeRoot' => true),
        );
        foreach($optionSets as $name => $options) {
            $options['test'] = true;
            $result = $this->pathFinder()->get($path, $options);
            $test = $result['test'];
            $results[$name] = $result;
            $tests[$name] = $test;
        }
        $defaultTest = $tests['defaults'];
        foreach(array_keys($optionSets) as $name) {
            $test = $tests[$name];
            $result = $results[$name];
            if($expectResponse && $result['response'] != $expectResponse) {
                $status = "FAIL ($result[response] != $expectResponse)";
            } else {
                $status = ($test === $defaultTest ? 'OK' : 'FAIL');
            }
            $testResults[] = array(
                'name' => $name,
                'status' => $status,
                'test' => $test
            );
        }

        return $testResults;
    }

    /**
     * @param Page $item
     * @return array
     *
     */
    public function testPage(Page $item) {
        $languages = $this->languages();
        $testResults = array();
        $defaultPath = $item->path();
        if($languages) {
            foreach($languages as $language) {
                // $user->setLanguage($language);
                $path = $item->localPath($language);
                if($language->isDefault() || $path === $defaultPath) {
                    $expect = 200;
                } else {
                    $expect = $item->get("status$language") > 0 ? 200 : 300;
                }
                $testResults["$language->name:$path"] = $this->testPath($path, $expect);
            }
        } else {
            $path = $item->path();
            $testResults[$path] = $this->testPath($path, 200);
        }
        return $testResults;
    }

    /**
     * @param string|PageArray $selector
     * @return array
     *
     */
    public function testPages($selector) {
        if($selector instanceof PageArray) {
            $items = $selector;
        } else {
            $items = $this->pages->find($selector);
        }
        $testResults = array();
        foreach($items as $item) {
            $testResults = array_merge($testResults, $this->testPage($item));
        }
        return $testResults;
    }
}