Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.13% covered (success)
99.13%
342 / 345
75.00% covered (success)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AddressParser
99.13% covered (success)
99.13%
342 / 345
75.00% covered (success)
75.00%
6 / 8
118
0.00% covered (danger)
0.00%
0 / 1
 parseStreetAddress
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
15
 clean
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 tokenize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 normalizeCase
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 normalizeWords
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 extractGeo
98.86% covered (success)
98.86%
173 / 175
0.00% covered (danger)
0.00%
0 / 1
55
 extractLocation
99.08% covered (success)
99.08%
108 / 109
0.00% covered (danger)
0.00%
0 / 1
40
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Parser\Address;
16
17use Pop\Parser\AbstractParser;
18use Pop\Parser\Exception;
19
20/**
21 * Address parser class
22 *
23 * @category   Pop
24 * @package    Pop\Parser
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    1.0.0
29 */
30class AddressParser extends AbstractParser
31{
32
33    /**
34     * Parse street address
35     *
36     * @param  string $streetAddress
37     * @return array
38     */
39    public function parseStreetAddress(string $streetAddress): array
40    {
41        $addressValues    = new AddressValues();
42        $lines            = $this->clean($streetAddress);
43        $tokens           = $this->tokenize($lines);
44        $locationResults  = $this->extractLocation($tokens, $addressValues);
45
46        return [
47            'streetNumber' => $locationResults['streetNumber'],
48            'streetName'   => $locationResults['streetName'],
49            'routeType'    => $locationResults['routeType'],
50            'direction'    => $locationResults['direction'],
51            'unit'         => $locationResults['unit'],
52        ];
53    }
54
55    /**
56     * Parse method
57     *
58     * @param  ?string $address
59     * @throws Exception
60     * @return AddressResult
61     */
62    public function parse(?string $address = null): AddressResult
63    {
64        if (empty($this->data) && empty($address)) {
65            throw new Exception('Error: You must pass an address string to the parser object.');
66        }
67
68        if ((null === $address) && !empty($this->data)) {
69            $address = $this->data;
70        } else if ((null !== $address) && empty($this->data)) {
71            $this->data = $address;
72        }
73
74        $addressValues = new AddressValues();
75        $lines         = $this->clean($address);
76        $tokens        = $this->tokenize($lines);
77        $geoResults    = $this->extractGeo($tokens, $addressValues);
78
79        $remainingLines = [];
80        foreach ($tokens as $i => $lineTokens) {
81            if (isset($geoResults['trimmedLines'][$i])) {
82                $remainingLines[] = $geoResults['trimmedLines'][$i];
83            } else if (!in_array($i, $geoResults['linesProcessed'])) {
84                $remainingLines[] = $lineTokens;
85            }
86        }
87
88        $locationResults = $this->extractLocation($remainingLines, $addressValues);
89
90        // Confidence signals: each represents a specific point where the pipeline had to
91        // guess/default rather than resolve something on solid (punctuation- or
92        // dataset-anchored) evidence. "city === null" is deliberately NOT one of these - an
93        // address that genuinely never included a city (e.g. "123 Main St, IL 62704") is a
94        // confident, correct null, not a guess failure.
95        $signalCount = 0;
96        if ($geoResults['routeBoundaryUsed']) {
97            $signalCount++;
98        }
99        if ($locationResults['primaryLineInferred']) {
100            $signalCount++;
101        }
102        if ($geoResults['postalCode'] === null) {
103            $signalCount++;
104        }
105        if (($geoResults['postalCode'] !== null) && ($geoResults['stateCode'] === null)) {
106            $signalCount++;
107        }
108
109        $this->result = new AddressResult(array_merge($locationResults, $geoResults, [
110            'confidence' => $this->calculateConfidence($signalCount),
111        ]));
112
113        return $this->result;
114    }
115
116    /**
117     * Clean method
118     *
119     * @param  string $address
120     * @return array
121     */
122    public function clean(string $address): array
123    {
124        // Split into array by comma, semi-colon, newline, and/or tab delimiters
125        $lines = preg_split('/,|\t|\n|\r|;/', $address);
126
127        return array_filter(array_map(function ($line) {
128            // Multiple spaces
129            $line = preg_replace('/\s+/', ' ', $line);
130
131            // Bad dash format
132            $line = str_replace([' -', '- '], '-', $line);
133
134            return trim($line);
135        }, $lines));
136    }
137
138    /**
139     * Tokenize method
140     *
141     * Splits each cleaned line into an array of whitespace-delimited word tokens, keyed by
142     * the original line index.
143     *
144     * @param  array $lines
145     * @return array
146     */
147    protected function tokenize(array $lines): array
148    {
149        $tokens = [];
150
151        foreach ($lines as $i => $line) {
152            $tokens[$i] = preg_split('/\s+/', trim($line));
153        }
154
155        return $tokens;
156    }
157
158    /**
159     * Normalize the case of a single word: an all-uppercase or all-lowercase word gets
160     * title-cased ("MAIN" / "main" -> "Main"); a word with any existing mixed case is left
161     * exactly as typed, since that mixed case is almost always deliberate. "Mc" gets one
162     * further, explicit fix-up: capitalize the letter right after it ("Mcgregor" ->
163     * "McGregor"), the same treatment applied by NameParser::normalizeCase() and for the same
164     * reason - it's a reliable prefix marker with very few false positives. Only ever applied
165     * to street name / city, never to postal code, state code, country code, route type, or
166     * direction, which must stay exactly as extracted (canonical/abbreviated forms, not prose).
167     *
168     * @param  string $word
169     * @return string
170     */
171    protected function normalizeCase(string $word): string
172    {
173        $stripped = str_replace('.', '', $word);
174
175        if (($stripped === mb_strtoupper($stripped)) || ($stripped === mb_strtolower($stripped))) {
176            $titled = preg_replace_callback('/\p{L}+/u', function ($matches) {
177                return mb_convert_case($matches[0], MB_CASE_TITLE);
178            }, $word);
179
180            return preg_replace_callback('/\bMc(\p{L})/u', function ($matches) {
181                return 'Mc' . mb_strtoupper($matches[1]);
182            }, $titled);
183        }
184
185        return $word;
186    }
187
188    /**
189     * Normalize the case of each word in an array and join them with a space
190     *
191     * @param  array $words
192     * @return string
193     */
194    protected function normalizeWords(array $words): string
195    {
196        return implode(' ', array_map([$this, 'normalizeCase'], $words));
197    }
198
199    /**
200     * Extract geo (country, postal code, state and city)
201     *
202     * Works right-to-left over the tokenized lines, using token *position* rather than
203     * substring matching: the postal code anchors everything else, the state is the token
204     * immediately before it, the city is whatever precedes the state within that same
205     * segment (or the entirely preceding segment), and the country is only recognized as a
206     * distinct segment outside the state slot. This ordering is what keeps a state code
207     * like "CA" from ever being mistaken for the country code "CA".
208     *
209     * @param  array         $tokens
210     * @param  AddressValues $addressValues
211     * @return array
212     */
213    protected function extractGeo(array $tokens, AddressValues $addressValues): array
214    {
215        $city       = null;
216        $state      = null;
217        $stateName  = null;
218        $stateCode  = null;
219        $postalCode = null;
220        $zip4       = null;
221        $country    = null;
222
223        $linesProcessed    = [];
224        $trimmedLines      = [];
225        $routeBoundaryUsed = false;
226
227        $usStates = $addressValues->getStates('US');
228        $caStates = $addressValues->getStates('CA');
229
230        $usZipRegex    = '/^\d{5}(-\d{4})?$/';
231        $usZip9Regex   = '/^\d{9}$/';
232        $caPostalRegex = '/^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$/i';
233        $poBoxRegex    = '/^(P\.?O\.?\s*Box|POB|Box)$/i';
234
235        $looksLikePoBoxLine = function(array $line) use ($poBoxRegex): bool {
236            return (preg_match($poBoxRegex, str_replace(' ', '', $line[0])) === 1)
237                || ((count($line) >= 2) && (strcasecmp(str_replace('.', '', $line[0]), 'PO') === 0) && (strcasecmp($line[1], 'Box') === 0));
238        };
239
240        $lineKeys = array_keys($tokens);
241        rsort($lineKeys);
242
243        $postalLine  = null;
244        $postalIndex = null;
245
246        // Find the postal code, scanning lines and tokens from the end backward
247        foreach ($lineKeys as $i) {
248            $lineTokens = $tokens[$i];
249            $count      = count($lineTokens);
250
251            for ($t = $count - 1; $t >= 0; $t--) {
252                $word = $lineTokens[$t];
253
254                if ((preg_match($usZipRegex, $word) === 1) || (preg_match($usZip9Regex, $word) === 1)) {
255                    $postalCode  = $word;
256                    $postalLine  = $i;
257                    $postalIndex = $t;
258                    $country     = 'US';
259                    break 2;
260                }
261
262                if (preg_match($caPostalRegex, $word) === 1) {
263                    $postalCode  = $word;
264                    $postalLine  = $i;
265                    $postalIndex = $t;
266                    $country     = 'CA';
267                    break 2;
268                }
269
270                // Canadian postal codes are sometimes written with an internal space, e.g. "M4B 1B3"
271                if ($t > 0) {
272                    $joined = $lineTokens[$t - 1] . ' ' . $word;
273                    if (preg_match($caPostalRegex, $joined) === 1) {
274                        $postalCode  = str_replace(' ', '', $joined);
275                        $postalLine  = $i;
276                        $postalIndex = $t - 1;
277                        $country     = 'CA';
278                        break 2;
279                    }
280                }
281            }
282        }
283
284        if ($postalCode !== null) {
285            $linesProcessed[] = $postalLine;
286
287            if ($country === 'US') {
288                if (strpos($postalCode, '-') !== false) {
289                    [$postalCode, $zip4] = explode('-', $postalCode);
290                } else if (strlen($postalCode) === 9) {
291                    $zip4       = substr($postalCode, -4);
292                    $postalCode = substr($postalCode, 0, 5);
293                }
294            }
295
296            // The state slot is the token immediately before the postal code, on the same
297            // line. If the postal code is the first token of its line, fall back to the
298            // last token of the nearest preceding unconsumed line.
299            $stateLine  = null;
300            $stateIndex = null;
301
302            if ($postalIndex > 0) {
303                $stateLine  = $postalLine;
304                $stateIndex = $postalIndex - 1;
305            } else {
306                foreach ($lineKeys as $i) {
307                    if (($i < $postalLine) && !in_array($i, $linesProcessed)) {
308                        $stateLine  = $i;
309                        $stateIndex = count($tokens[$i]) - 1;
310                        break;
311                    }
312                }
313            }
314
315            if ($stateLine !== null) {
316                // Try progressively longer token spans ending at $stateIndex (longest first),
317                // so multi-word state/province names ("New York", "District of Columbia",
318                // "Prince Edward Island") resolve, not just the single trailing token.
319                $stateSpanStart = $stateIndex;
320
321                for ($span = min(3, $stateIndex + 1); $span >= 1; $span--) {
322                    $spanStart      = $stateIndex - $span + 1;
323                    $candidate      = implode(' ', array_slice($tokens[$stateLine], $spanStart, $span));
324                    $candidateUpper = strtoupper($candidate);
325
326                    if (($country === 'US') && ($span === 1) && (strlen($candidate) === 2) && isset($usStates[$candidateUpper])) {
327                        $state          = $candidateUpper;
328                        $stateCode      = $candidateUpper;
329                        $stateName      = $usStates[$candidateUpper];
330                        $stateSpanStart = $spanStart;
331                        break;
332                    } else if (($country === 'CA') && ($span === 1) && (strlen($candidate) === 2) && isset($caStates[$candidateUpper])) {
333                        $state          = $candidateUpper;
334                        $stateCode      = $candidateUpper;
335                        $stateName      = $caStates[$candidateUpper];
336                        $stateSpanStart = $spanStart;
337                        break;
338                    } else if (($fullMatch = array_search($candidate, $usStates)) !== false) {
339                        $state          = $candidate;
340                        $stateCode      = $fullMatch;
341                        $stateName      = $candidate;
342                        $stateSpanStart = $spanStart;
343                        break;
344                    } else if (($fullMatch = array_search($candidate, $caStates)) !== false) {
345                        $state          = $candidate;
346                        $stateCode      = $fullMatch;
347                        $stateName      = $candidate;
348                        $stateSpanStart = $spanStart;
349                        break;
350                    }
351                }
352
353                if ($state !== null) {
354                    if (!in_array($stateLine, $linesProcessed)) {
355                        $linesProcessed[] = $stateLine;
356                    }
357
358                    // A comma already separated an earlier segment from this one if any lower
359                    // line index exists at all - in that case, whatever precedes the state
360                    // in THIS line's own tokens (or the nearest preceding line, if this line's
361                    // "before" is empty) is unambiguously city, because the street already got
362                    // its own segment earlier. Only when there's no preceding segment - a truly
363                    // comma-less single-line address - can this line's leading tokens be a
364                    // street/city hybrid that needs the route-type-boundary split below.
365                    $hasPrecedingLine = false;
366                    foreach ($lineKeys as $i) {
367                        if ($i < $stateLine) {
368                            $hasPrecedingLine = true;
369                            break;
370                        }
371                    }
372
373                    $routeTypes = array_merge(
374                        array_map('strtolower', $addressValues->getRouteTypes(true)),
375                        $addressValues->getCommonRouteTypes()
376                    );
377                    $routeTypeSet = array_flip($routeTypes);
378
379                    // Prefer the RIGHTMOST route-type match that still leaves at least
380                    // one token after it (for a city). Neither "first" nor "last" alone
381                    // works: taking the last match breaks when a city name itself ends in
382                    // a route-type word ("Beverly Hills" - "Hills" is a valid suffix), and
383                    // taking the first match breaks when the street name itself starts
384                    // with a route-type word ("Park Ave ...", "Circle Dr ..."). A match
385                    // with nothing after it is far more likely to be the tail of the city
386                    // name than the street's actual route suffix, since a route suffix is
387                    // normally followed by a city.
388                    $findRouteBoundary = function(array $span) use ($routeTypeSet, $looksLikePoBoxLine): ?int {
389                        // A span with no digit/PO-Box evidence at its head can only plausibly be
390                        // a place name (e.g. "Lake Forest"), not a street/city hybrid - don't let
391                        // a route-type word that's also a legitimate city-name word ("Lake",
392                        // "Park", "Hills", ...) be mistaken for a street's route-type suffix here.
393                        if ((preg_match('/^\d/', $span[0]) !== 1) && !$looksLikePoBoxLine($span)) {
394                            return null;
395                        }
396
397                        $routeEndIndex   = null;
398                        $lastSpanIndex   = count($span) - 1;
399                        foreach ($span as $idx => $word) {
400                            $routeCandidate = strtolower(rtrim($word, '.'));
401                            if (isset($routeTypeSet[$routeCandidate]) && ($idx < $lastSpanIndex)) {
402                                $routeEndIndex = $idx;
403                            }
404                        }
405                        return $routeEndIndex;
406                    };
407
408                    // City: remaining tokens before the state, in the same line
409                    $before = array_slice($tokens[$stateLine], 0, $stateSpanStart);
410                    if (!empty($before)) {
411                        if ($hasPrecedingLine) {
412                            // A comma already separates this from the street - it's just city.
413                            $city = $this->normalizeWords($before);
414                        } else {
415                            // This line carries city AND (with no comma to separate them)
416                            // possibly the street portion too. Find where the street portion
417                            // ends (its route-type suffix, if any) so city only takes what's
418                            // left over, and hand the leading portion back for street parsing
419                            // rather than losing it. If no route-type boundary can be found,
420                            // city is left unguessed (null) rather than swallowing words that
421                            // might be street, not city.
422                            $routeEndIndex = $findRouteBoundary($before);
423
424                            if ($routeEndIndex !== null) {
425                                $city              = $this->normalizeWords(array_slice($before, $routeEndIndex + 1));
426                                $routeBoundaryUsed = true;
427                                if ($city === '') {
428                                    $city = null;
429                                }
430                                $trimmedLines[$stateLine] = array_slice($before, 0, $routeEndIndex + 1);
431                            } else {
432                                $trimmedLines[$stateLine] = $before;
433                            }
434                        }
435                    } else {
436                        // Fall back to the nearest preceding unconsumed line. If that line
437                        // still looks like it carries the street (a route-type boundary can be
438                        // found in it, it starts with a number, or it's a PO Box line), don't
439                        // swallow it whole as city - split it the same way, or hand it back
440                        // unsplit for street parsing, rather than silently discarding the street.
441                        foreach ($lineKeys as $i) {
442                            if (($i < $stateLine) && !in_array($i, $linesProcessed)) {
443                                $candidateLine = $tokens[$i];
444                                $routeEndIndex = $findRouteBoundary($candidateLine);
445
446                                if ($routeEndIndex !== null) {
447                                    $city              = $this->normalizeWords(array_slice($candidateLine, $routeEndIndex + 1));
448                                    $routeBoundaryUsed = true;
449                                    if ($city === '') {
450                                        $city = null;
451                                    }
452                                    $trimmedLines[$i] = array_slice($candidateLine, 0, $routeEndIndex + 1);
453                                } else if ((preg_match('/^\d/', $candidateLine[0]) === 1) || $looksLikePoBoxLine($candidateLine)) {
454                                    $trimmedLines[$i] = $candidateLine;
455                                } else {
456                                    $city = $this->normalizeWords($candidateLine);
457                                }
458
459                                $linesProcessed[] = $i;
460                                break;
461                            }
462                        }
463                    }
464                }
465            }
466        }
467
468        // Country is only recognized as a distinct, unconsumed segment (never the state slot).
469        // Bare two-letter codes ("US"/"CA") are deliberately excluded here - only unambiguous
470        // full forms are accepted - because a bare "CA" can only safely be trusted as a state
471        // when the state-slot mechanism above resolves it; without a postal code to anchor
472        // that slot, a bare "CA" segment must not silently become "Canada" instead.
473        $countryLineValues = [
474            'US' => ['USA', 'U S A', 'UNITED STATES'],
475            'CA' => ['CAN', 'CANADA'],
476        ];
477
478        foreach ($lineKeys as $i) {
479            if (in_array($i, $linesProcessed)) {
480                continue;
481            }
482
483            $normalizedLine = strtoupper(str_replace('.', '', implode(' ', $tokens[$i])));
484
485            if (in_array($normalizedLine, $countryLineValues['CA'], true)) {
486                $country          = 'CA';
487                $linesProcessed[] = $i;
488                break;
489            }
490
491            if (in_array($normalizedLine, $countryLineValues['US'], true)) {
492                $country          = 'US';
493                $linesProcessed[] = $i;
494                break;
495            }
496        }
497
498        return [
499            'city'              => $city,
500            'stateName'         => $stateName,
501            'stateCode'         => $stateCode,
502            'postalCode'        => $postalCode,
503            'zip4'              => $zip4,
504            'country'           => $country,
505            'linesProcessed'    => $linesProcessed,
506            'trimmedLines'      => $trimmedLines,
507            'routeBoundaryUsed' => $routeBoundaryUsed,
508        ];
509    }
510
511    /**
512     * Extract street/location details (PO Box, unit, direction, route type, street number/name)
513     *
514     * Operates on whatever lines extractGeo() didn't consume. The primary (first) line is
515     * where the street number, name, route type and direction are extracted from; a
516     * secondary line is only pulled in if it looks like a unit (e.g. a comma-separated
517     * "Apt 3B" segment) so that an unrecognized trailing line (e.g. a city extractGeo()
518     * couldn't place) is never merged into the street name. Each step removes the tokens it
519     * claims before the next step runs, so nothing can be claimed twice.
520     *
521     * @param  array         $lines
522     * @param  AddressValues $addressValues
523     * @return array
524     */
525    protected function extractLocation(array $lines, AddressValues $addressValues): array
526    {
527        $lines = array_values($lines);
528
529        $streetNumber      = null;
530        $streetName        = null;
531        $routeType         = null;
532        $unit              = null;
533        $direction         = null;
534        $directionPosition = null;
535        $isPoBox           = false;
536
537        if (empty($lines)) {
538            $primaryLineInferred = false;
539            return compact('streetNumber', 'streetName', 'routeType', 'direction', 'directionPosition', 'unit', 'isPoBox', 'primaryLineInferred');
540        }
541
542        $unitTypes = array_map('strtoupper', $addressValues->getUnitTypes());
543        $unitTypeSet = array_flip($unitTypes);
544        $routeTypes = array_merge(
545            array_map('strtolower', $addressValues->getRouteTypes(true)),
546            $addressValues->getCommonRouteTypes()
547        );
548        $routeTypeSet = array_flip($routeTypes);
549        $poBoxRegex = '/^(P\.?O\.?\s*Box|POB|Box)$/i';
550
551        // Pick the primary (street) line: the first remaining line with STRONG evidence of
552        // being the street - a leading number AND a trailing route-type word together, or a
553        // match for the PO Box pattern. This matters when a non-street line sorts ahead of the
554        // real street line (e.g. a recipient name: "John Smith, 123 Main St, ..."); without
555        // it, the recipient name would be mistaken for the street name and the real street
556        // silently dropped. Requiring BOTH signals (not just one) matters just as much: a line
557        // that only weakly matches one signal - e.g. "4th Floor" starts with a digit but isn't
558        // a street - must not be promoted over the true street line just because that line
559        // (e.g. "Broadway") has no recognizable route-type suffix of its own. Falls back to
560        // the first line when nothing qualifies.
561        $primaryIndex        = 0;
562        $primaryLineInferred = true;
563        foreach ($lines as $idx => $line) {
564            $lastLineIndex = count($line) - 1;
565            $looksLikePoBox = (preg_match($poBoxRegex, str_replace(' ', '', $line[0])) === 1)
566                || ((count($line) >= 2) && (strcasecmp(str_replace('.', '', $line[0]), 'PO') === 0) && (strcasecmp($line[1], 'Box') === 0));
567            $looksLikeStreet = $looksLikePoBox
568                || ((preg_match('/^\d/', $line[0]) === 1) && isset($routeTypeSet[strtolower(rtrim($line[$lastLineIndex], '.'))]));
569            if ($looksLikeStreet) {
570                $primaryIndex        = $idx;
571                $primaryLineInferred = false;
572                break;
573            }
574        }
575
576        $secondaryLines = $lines;
577        unset($secondaryLines[$primaryIndex]);
578
579        // A trailing (non-primary) line is only pulled in as a unit if it looks like one: a
580        // recognized designator word co-occurring with a digit (e.g. "Apt 3B"), or a bare
581        // "#..." token. A designator word alone isn't enough - several unit-type words
582        // ("Front", "Rear", "Lobby", "Pier", "Side", "Fl", ...) are also ordinary English
583        // words that appear in real street names, so requiring a digit too is what keeps an
584        // unrecognized line like "FL" (a state, with no zip to anchor it) from being
585        // mistaken for a unit. Anything that doesn't qualify is left alone rather than
586        // merged into the street name.
587        foreach ($secondaryLines as $line) {
588            $hasDesignator = false;
589            $hasDigit      = false;
590            foreach ($line as $word) {
591                if (isset($unitTypeSet[strtoupper(rtrim($word, '.'))])) {
592                    $hasDesignator = true;
593                }
594                if (preg_match('/\d/', $word) === 1) {
595                    $hasDigit = true;
596                }
597            }
598            if (($hasDesignator && $hasDigit) || str_starts_with($line[0], '#')) {
599                $unit = implode(' ', $line);
600                break;
601            }
602        }
603
604        $tokens = $lines[$primaryIndex];
605
606        // PO Box, e.g. "PO Box 1234", "P.O. Box 1234", "POB 1234", "Box 1234"
607        if ((count($tokens) >= 2) && (preg_match($poBoxRegex, str_replace(' ', '', $tokens[0])) === 1)
608            && (preg_match('/^\d+[A-Za-z]?$/', $tokens[1]) === 1)) {
609            return [
610                'streetNumber'        => null,
611                'streetName'          => 'PO Box ' . $tokens[1],
612                'routeType'           => null,
613                'direction'           => null,
614                'directionPosition'   => null,
615                'unit'                => $unit,
616                'isPoBox'             => true,
617                'primaryLineInferred' => $primaryLineInferred,
618            ];
619        }
620        // "PO" "Box" "1234" as three separate tokens (e.g. from "P.O. Box 1234")
621        if ((count($tokens) >= 3) && (strcasecmp(str_replace('.', '', $tokens[0]), 'PO') === 0)
622            && (strcasecmp($tokens[1], 'Box') === 0) && (preg_match('/^\d+[A-Za-z]?$/', $tokens[2]) === 1)) {
623            return [
624                'streetNumber'        => null,
625                'streetName'          => 'PO Box ' . $tokens[2],
626                'routeType'           => null,
627                'direction'           => null,
628                'directionPosition'   => null,
629                'unit'                => $unit,
630                'isPoBox'             => true,
631                'primaryLineInferred' => $primaryLineInferred,
632            ];
633        }
634
635        // Unit designator within the primary line: anchored to the tail (a bare "#..." last
636        // token, or a recognized designator word immediately followed by a value token that
637        // contains a digit). Anchoring here - rather than scanning the whole line - is what
638        // keeps a street name like "123 Front St" or "500 Pier Rd" from having its second
639        // word mistaken for a unit designator; "Front"/"Pier" are unit-type words too, but
640        // "St"/"Rd" right after them don't look like a unit value.
641        if ($unit === null) {
642            $lastIndex = count($tokens) - 1;
643            if (($lastIndex >= 0) && str_starts_with($tokens[$lastIndex], '#')) {
644                $unit = $tokens[$lastIndex];
645                array_splice($tokens, $lastIndex, 1);
646            } else if ($lastIndex >= 1) {
647                $designatorCandidate = strtoupper(rtrim($tokens[$lastIndex - 1], '.'));
648                if (isset($unitTypeSet[$designatorCandidate]) && (preg_match('/\d/', $tokens[$lastIndex]) === 1)) {
649                    $unit = $tokens[$lastIndex - 1] . ' ' . $tokens[$lastIndex];
650                    array_splice($tokens, $lastIndex - 1, 2);
651                }
652            }
653        }
654
655        // Direction: recognized only as a prefix (immediately after the street number) or a
656        // suffix (the very last remaining token) - never in the middle of the street name.
657        $directionSet = [];
658        foreach ($addressValues->getDirections() as $value) {
659            $directionSet[strtoupper(trim($value))] = true;
660        }
661
662        if (count($tokens) > 1) {
663            $prefixCandidate = strtoupper(rtrim($tokens[1], '.'));
664            if (isset($directionSet[$prefixCandidate])) {
665                $direction         = $tokens[1];
666                $directionPosition = 0;
667                array_splice($tokens, 1, 1);
668            }
669        }
670        if (($direction === null) && (count($tokens) > 1)) {
671            $lastIndex       = count($tokens) - 1;
672            $suffixCandidate = strtoupper(rtrim($tokens[$lastIndex], '.'));
673            if (isset($directionSet[$suffixCandidate])) {
674                $direction         = $tokens[$lastIndex];
675                $directionPosition = 1;
676                array_splice($tokens, $lastIndex, 1);
677            }
678        }
679
680        // Route type: only recognized as the last remaining token, not merely present
681        // anywhere in the street name (this is what fixes e.g. "Park" in "Park Granada"
682        // being mistaken for a route-type suffix).
683        if (!empty($tokens)) {
684            $lastIndex = count($tokens) - 1;
685            $candidate = strtolower(rtrim($tokens[$lastIndex], '.'));
686            if (isset($routeTypeSet[$candidate])) {
687                $routeType = $tokens[$lastIndex];
688                array_splice($tokens, $lastIndex, 1);
689            }
690        }
691
692        // Street number / name
693        if (!empty($tokens)) {
694            if (preg_match('/^\d/', $tokens[0]) === 1) {
695                $streetNumber = $tokens[0];
696                $streetName   = $this->normalizeWords(array_slice($tokens, 1));
697            } else {
698                $streetName = $this->normalizeWords($tokens);
699            }
700            if ($streetName === '') {
701                $streetName = null;
702            }
703        }
704
705        return compact('streetNumber', 'streetName', 'routeType', 'direction', 'directionPosition', 'unit', 'isPoBox', 'primaryLineInferred');
706    }
707
708}