Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.78% covered (success)
86.78%
348 / 401
90.62% covered (success)
90.62%
29 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
AddressParser
86.78% covered (success)
86.78%
348 / 401
90.62% covered (success)
90.62%
29 / 32
208.76
0.00% covered (danger)
0.00%
0 / 1
 getStreetNumber
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStreetName
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getRouteType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDirection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUnit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPostalCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getZip4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStateName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStateCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCountry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasStreetNumber
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasStreetName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRouteType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDirection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasUnit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasPostalCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasZip4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasStateName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasStateCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasCountry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isPoBox
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFullAddress
81.82% covered (success)
81.82%
27 / 33
0.00% covered (danger)
0.00%
0 / 1
21.17
 parseStreetAddress
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
10
 toArray
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 clean
100.00% covered (success)
100.00%
3 / 3
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
 extractGeo
79.76% covered (success)
79.76%
134 / 168
0.00% covered (danger)
0.00%
0 / 1
76.28
 extractLocation
87.25% covered (success)
87.25%
89 / 102
0.00% covered (danger)
0.00%
0 / 1
43.31
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Pop PHP Framework (https://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@noladev.com>
7 * @copyright  Copyright (c) 2009-2026 NOLA Interactive, LLC.
8 * @license    https://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Parser\Address;
15
16use Pop\Parser\AbstractParser;
17use Pop\Parser\Exception;
18
19/**
20 * Address parser class
21 *
22 * @category   Pop
23 * @package    Pop\Parser
24 * @author     Nick Sagona, III <dev@noladev.com>
25 * @copyright  Copyright (c) 2009-2026 NOLA Interactive, LLC.
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    1.0.0
28 */
29class AddressParser extends AbstractParser
30{
31
32    /**
33     * Street number
34     * @var ?string
35     * */
36    protected ?string $streetNumber = null;
37
38    /**
39     * Street name
40     * @var ?string
41     * */
42    protected ?string $streetName = null;
43
44    /**
45     * Route type
46     * @var ?string
47     * */
48    protected ?string $routeType = null;
49
50    /**
51     * Direction
52     * @var ?string
53     * */
54    protected ?string $direction = null;
55
56    /**
57     * Direction position
58     * @var ?int
59     * */
60    protected ?int $directionPosition = null;
61
62    /**
63     * Unit
64     * @var ?string
65     * */
66    protected ?string $unit = null;
67
68    /**
69     * City
70     * @var ?string
71     * */
72    protected ?string $city = null;
73
74    /**
75     * Postal code
76     * @var ?string
77     * */
78    protected ?string $postalCode = null;
79
80    /**
81     * Zip 4
82     * @var ?string
83     * */
84    protected ?string $zip4 = null;
85
86    /**
87     * State name
88     * @var ?string
89     * */
90    protected ?string $stateName = null;
91
92    /**
93     * State code
94     * @var ?string
95     * */
96    protected ?string $stateCode = null;
97
98    /**
99     * Country
100     * @var ?string
101     * */
102    protected ?string $country = null;
103
104    /**
105     * PO Box flag
106     * @var bool
107     * */
108    protected bool $isPoBox = false;
109
110    /**
111     * Method to get street number
112     *
113     * @return ?string
114     */
115    public function getStreetNumber(): ?string
116    {
117        return $this->streetNumber;
118    }
119
120    /**
121     * Method to get street name
122     *
123     * @param  bool $withRouteDirection
124     * @return ?string
125     */
126    public function getStreetName(bool $withRouteDirection = true): ?string
127    {
128        $streetName = $this->streetName;
129        if (!empty($this->direction) && ($withRouteDirection)) {
130            if ($this->directionPosition == 1) {
131                $streetName = $streetName . ' ' . $this->direction;
132            } else {
133                $streetName = $this->direction . ' ' . $streetName;
134            }
135        }
136
137        return $streetName;
138    }
139
140    /**
141     * Method to get route type
142     *
143     * @return ?string
144     */
145    public function getRouteType(): ?string
146    {
147        return $this->routeType;
148    }
149
150    /**
151     * Method to get direction
152     *
153     * @return ?string
154     */
155    public function getDirection(): ?string
156    {
157        return $this->direction;
158    }
159
160    /**
161     * Method to get unit
162     *
163     * @return ?string
164     */
165    public function getUnit(): ?string
166    {
167        return $this->unit;
168    }
169
170    /**
171     * Method to get city
172     *
173     * @return ?string
174     */
175    public function getCity(): ?string
176    {
177        return $this->city;
178    }
179
180    /**
181     * Method to get postal code
182     *
183     * @return ?string
184     */
185    public function getPostalCode(): ?string
186    {
187        return $this->postalCode;
188    }
189
190    /**
191     * Method to get zip 4
192     *
193     * @return ?string
194     */
195    public function getZip4(): ?string
196    {
197        return $this->zip4;
198    }
199
200    /**
201     * Method to get state name
202     *
203     * @return ?string
204     */
205    public function getStateName(): ?string
206    {
207        return $this->stateName;
208    }
209
210    /**
211     * Method to get state code
212     *
213     * @return ?string
214     */
215    public function getStateCode(): ?string
216    {
217        return $this->stateCode;
218    }
219
220    /**
221     * Method to get country
222     *
223     * @return ?string
224     */
225    public function getCountry(): ?string
226    {
227        return $this->country;
228    }
229
230    /**
231     * Has street number
232     *
233     * @return bool
234     */
235    public function hasStreetNumber(): bool
236    {
237        return !empty($this->streetNumber);
238    }
239
240    /**
241     * Has street name
242     *
243     * @return bool
244     */
245    public function hasStreetName(): bool
246    {
247        return !empty($this->streetName);
248    }
249
250    /**
251     * Has route type
252     *
253     * @return bool
254     */
255    public function hasRouteType(): bool
256    {
257        return !empty($this->routeType);
258    }
259
260    /**
261     * Has direction
262     *
263     * @return bool
264     */
265    public function hasDirection(): bool
266    {
267        return !empty($this->direction);
268    }
269
270    /**
271     * Has unit
272     *
273     * @return bool
274     */
275    public function hasUnit(): bool
276    {
277        return !empty($this->unit);
278    }
279
280    /**
281     * Has city
282     *
283     * @return bool
284     */
285    public function hasCity(): bool
286    {
287        return !empty($this->city);
288    }
289
290    /**
291     * Has postal code
292     *
293     * @return bool
294     */
295    public function hasPostalCode(): bool
296    {
297        return !empty($this->postalCode);
298    }
299
300    /**
301     * Has zip 4
302     *
303     * @return bool
304     */
305    public function hasZip4(): bool
306    {
307        return !empty($this->zip4);
308    }
309
310    /**
311     * Has state name
312     *
313     * @return bool
314     */
315    public function hasStateName(): bool
316    {
317        return !empty($this->stateName);
318    }
319
320    /**
321     * Has state code
322     *
323     * @return bool
324     */
325    public function hasStateCode(): bool
326    {
327        return !empty($this->stateCode);
328    }
329
330    /**
331     * Has country
332     *
333     * @return bool
334     */
335    public function hasCountry(): bool
336    {
337        return !empty($this->country);
338    }
339
340    /**
341     * Is PO Box
342     *
343     * @return bool
344     */
345    public function isPoBox(): bool
346    {
347        return $this->isPoBox;
348    }
349
350    /**
351     * Method to get full address
352     *
353     * @param  string $delimiter
354     * @param  bool   $useStateCode
355     * @param  bool   $includeCountry
356     * @return string
357     */
358    public function getFullAddress(string $delimiter = ', ', bool $useStateCode = true, bool $includeCountry = false): string
359    {
360        $fullAddress  = [];
361        $addressLine1 = null;
362
363        if (!empty($this->streetNumber)) {
364            $addressLine1 = $this->streetNumber;
365        }
366
367        if (!empty($this->streetName)) {
368            $streetName = $this->streetName;
369
370            if (!empty($this->direction)) {
371                if ($this->directionPosition == 1) {
372                    $streetName = $streetName . ' ' . $this->direction;
373                } else {
374                    $streetName = $this->direction . ' ' . $streetName;
375                }
376            }
377
378            if (!empty($this->routeType)) {
379                $streetName .= ' ' . $this->routeType;
380            }
381
382            if (!empty($addressLine1)) {
383                $addressLine1 .= ' ' . $streetName;
384            } else {
385                $addressLine1 = $streetName;
386            }
387        }
388
389        if (!empty($addressLine1)) {
390            $fullAddress[] = $addressLine1;
391        }
392
393        if (!empty($this->unit)) {
394            $fullAddress[] = $this->unit;
395        }
396
397        if (!empty($this->city) && (!empty($this->stateCode) || !empty($this->stateName))) {
398            $cityState = $this->city;
399            if (($useStateCode) && !empty($this->stateCode)) {
400                $cityState .= ', ' . $this->stateCode;
401            } else if (!empty($this->stateName)) {
402                $cityState .= ', ' . $this->stateName;
403            }
404
405            if (!empty($this->postalCode)) {
406                $cityState .= ' ' . $this->postalCode;
407                if (!empty($this->zip4)) {
408                    $cityState .= '-' . $this->zip4;
409                }
410            }
411
412            $fullAddress[] = $cityState;
413        }
414
415        if (($includeCountry) && !empty($this->country)) {
416            $fullAddress[] = $this->country;
417        }
418
419        return implode($delimiter, $fullAddress);
420    }
421
422    /**
423     * Parse street address
424     *
425     * @param  string $streetAddress
426     * @return array
427     */
428    public function parseStreetAddress(string $streetAddress): array
429    {
430        $addressValues    = new AddressValues();
431        $lines            = $this->clean($streetAddress);
432        $tokens           = $this->tokenize($lines);
433        $locationResults  = $this->extractLocation($tokens, $addressValues);
434
435        $this->streetNumber      = $locationResults['streetNumber'];
436        $this->streetName        = $locationResults['streetName'];
437        $this->routeType         = $locationResults['routeType'];
438        $this->direction         = $locationResults['direction'];
439        $this->directionPosition = $locationResults['directionPosition'];
440        $this->unit               = $locationResults['unit'];
441        $this->isPoBox            = $locationResults['isPoBox'];
442
443        return [
444            'streetNumber' => $this->streetNumber,
445            'streetName'   => $this->streetName,
446            'routeType'    => $this->routeType,
447            'direction'    => $this->direction,
448            'unit'         => $this->unit,
449        ];
450    }
451
452    /**
453     * Parse method
454     *
455     * @param  ?string $address
456     * @throws Exception
457     * @return static
458     */
459    public function parse(?string $address = null): static
460    {
461        if (empty($this->data) && empty($address)) {
462            throw new Exception('Error: You must pass an address string to the parser object.');
463        }
464
465        if ((null === $address) && !empty($this->data)) {
466            $address = $this->data;
467        } else if ((null !== $address) && empty($this->data)) {
468            $this->data = $address;
469        }
470
471        $addressValues = new AddressValues();
472        $lines         = $this->clean($address);
473        $tokens        = $this->tokenize($lines);
474        $geoResults    = $this->extractGeo($tokens, $addressValues);
475
476        $remainingLines = [];
477        foreach ($tokens as $i => $lineTokens) {
478            if (isset($geoResults['trimmedLines'][$i])) {
479                $remainingLines[] = $geoResults['trimmedLines'][$i];
480            } else if (!in_array($i, $geoResults['linesProcessed'])) {
481                $remainingLines[] = $lineTokens;
482            }
483        }
484
485        $locationResults = $this->extractLocation($remainingLines, $addressValues);
486
487        $this->streetNumber      = $locationResults['streetNumber'];
488        $this->streetName        = $locationResults['streetName'];
489        $this->routeType         = $locationResults['routeType'];
490        $this->direction         = $locationResults['direction'];
491        $this->directionPosition = $locationResults['directionPosition'];
492        $this->unit               = $locationResults['unit'];
493        $this->isPoBox            = $locationResults['isPoBox'];
494        $this->city               = $geoResults['city'];
495        $this->postalCode         = $geoResults['postalCode'];
496        $this->zip4               = $geoResults['zip4'];
497        $this->stateName          = $geoResults['stateName'];
498        $this->stateCode          = $geoResults['stateCode'];
499        $this->country            = $geoResults['country'];
500
501        return $this;
502    }
503
504    /**
505     * To array method
506     *
507     * @return array
508     */
509    public function toArray(): array
510    {
511        return [
512            'streetNumber' => $this->streetNumber,
513            'streetName'   => $this->streetName,
514            'routeType'    => $this->routeType,
515            'direction'    => $this->direction,
516            'unit'         => $this->unit,
517            'city'         => $this->city,
518            'postalCode'   => $this->postalCode,
519            'zip4'         => $this->zip4,
520            'stateName'    => $this->stateName,
521            'stateCode'    => $this->stateCode,
522            'country'      => $this->country,
523        ];
524    }
525
526    /**
527     * Clean method
528     *
529     * @param  string $address
530     * @return array
531     */
532    public function clean(string $address): array
533    {
534        // Multiple spaces
535        $address = preg_replace('/\s+/', ' ', $address);
536
537        // Bad dash format
538        $address = str_replace([' -', '- '], '-', $address);
539
540        // Split into array by comma, semi-colon, newline, and/or tab delimiters
541        return array_filter(array_map('trim', preg_split('/,|\t|\n|\r|;/', $address)));
542    }
543
544    /**
545     * Tokenize method
546     *
547     * Splits each cleaned line into an array of whitespace-delimited word tokens, keyed by
548     * the original line index.
549     *
550     * @param  array $lines
551     * @return array
552     */
553    protected function tokenize(array $lines): array
554    {
555        $tokens = [];
556
557        foreach ($lines as $i => $line) {
558            $tokens[$i] = preg_split('/\s+/', trim($line));
559        }
560
561        return $tokens;
562    }
563
564    /**
565     * Extract geo (country, postal code, state and city)
566     *
567     * Works right-to-left over the tokenized lines, using token *position* rather than
568     * substring matching: the postal code anchors everything else, the state is the token
569     * immediately before it, the city is whatever precedes the state within that same
570     * segment (or the entirely preceding segment), and the country is only recognized as a
571     * distinct segment outside the state slot. This ordering is what keeps a state code
572     * like "CA" from ever being mistaken for the country code "CA".
573     *
574     * @param  array         $tokens
575     * @param  AddressValues $addressValues
576     * @return array
577     */
578    protected function extractGeo(array $tokens, AddressValues $addressValues): array
579    {
580        $city       = null;
581        $state      = null;
582        $stateName  = null;
583        $stateCode  = null;
584        $postalCode = null;
585        $zip4       = null;
586        $country    = null;
587
588        $linesProcessed = [];
589        $trimmedLines   = [];
590
591        $usStates = $addressValues->getStates('US');
592        $caStates = $addressValues->getStates('CA');
593
594        $usZipRegex    = '/^\d{5}(-\d{4})?$/';
595        $usZip9Regex   = '/^\d{9}$/';
596        $caPostalRegex = '/^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$/i';
597        $poBoxRegex    = '/^(P\.?O\.?\s*Box|POB|Box)$/i';
598
599        $looksLikePoBoxLine = function(array $line) use ($poBoxRegex): bool {
600            return (preg_match($poBoxRegex, str_replace(' ', '', $line[0])) === 1)
601                || ((count($line) >= 2) && (strcasecmp(str_replace('.', '', $line[0]), 'PO') === 0) && (strcasecmp($line[1], 'Box') === 0));
602        };
603
604        $lineKeys = array_keys($tokens);
605        rsort($lineKeys);
606
607        $postalLine  = null;
608        $postalIndex = null;
609
610        // Find the postal code, scanning lines and tokens from the end backward
611        foreach ($lineKeys as $i) {
612            $lineTokens = $tokens[$i];
613            $count      = count($lineTokens);
614
615            for ($t = $count - 1; $t >= 0; $t--) {
616                $word = $lineTokens[$t];
617
618                if ((preg_match($usZipRegex, $word) === 1) || (preg_match($usZip9Regex, $word) === 1)) {
619                    $postalCode  = $word;
620                    $postalLine  = $i;
621                    $postalIndex = $t;
622                    $country     = 'US';
623                    break 2;
624                }
625
626                if (preg_match($caPostalRegex, $word) === 1) {
627                    $postalCode  = $word;
628                    $postalLine  = $i;
629                    $postalIndex = $t;
630                    $country     = 'CA';
631                    break 2;
632                }
633
634                // Canadian postal codes are sometimes written with an internal space, e.g. "M4B 1B3"
635                if ($t > 0) {
636                    $joined = $lineTokens[$t - 1] . ' ' . $word;
637                    if (preg_match($caPostalRegex, $joined) === 1) {
638                        $postalCode  = str_replace(' ', '', $joined);
639                        $postalLine  = $i;
640                        $postalIndex = $t - 1;
641                        $country     = 'CA';
642                        break 2;
643                    }
644                }
645            }
646        }
647
648        if ($postalCode !== null) {
649            $linesProcessed[] = $postalLine;
650
651            if ($country === 'US') {
652                if (strpos($postalCode, '-') !== false) {
653                    [$postalCode, $zip4] = explode('-', $postalCode);
654                } else if (strlen($postalCode) === 9) {
655                    $zip4       = substr($postalCode, -4);
656                    $postalCode = substr($postalCode, 0, 5);
657                }
658            }
659
660            // The state slot is the token immediately before the postal code, on the same
661            // line. If the postal code is the first token of its line, fall back to the
662            // last token of the nearest preceding unconsumed line.
663            $stateLine  = null;
664            $stateIndex = null;
665
666            if ($postalIndex > 0) {
667                $stateLine  = $postalLine;
668                $stateIndex = $postalIndex - 1;
669            } else {
670                foreach ($lineKeys as $i) {
671                    if (($i < $postalLine) && !in_array($i, $linesProcessed)) {
672                        $stateLine  = $i;
673                        $stateIndex = count($tokens[$i]) - 1;
674                        break;
675                    }
676                }
677            }
678
679            if ($stateLine !== null) {
680                // Try progressively longer token spans ending at $stateIndex (longest first),
681                // so multi-word state/province names ("New York", "District of Columbia",
682                // "Prince Edward Island") resolve, not just the single trailing token.
683                $stateSpanStart = $stateIndex;
684
685                for ($span = min(3, $stateIndex + 1); $span >= 1; $span--) {
686                    $spanStart      = $stateIndex - $span + 1;
687                    $candidate      = implode(' ', array_slice($tokens[$stateLine], $spanStart, $span));
688                    $candidateUpper = strtoupper($candidate);
689
690                    if (($country === 'US') && ($span === 1) && (strlen($candidate) === 2) && isset($usStates[$candidateUpper])) {
691                        $state          = $candidateUpper;
692                        $stateCode      = $candidateUpper;
693                        $stateName      = $usStates[$candidateUpper];
694                        $stateSpanStart = $spanStart;
695                        break;
696                    } else if (($country === 'CA') && ($span === 1) && (strlen($candidate) === 2) && isset($caStates[$candidateUpper])) {
697                        $state          = $candidateUpper;
698                        $stateCode      = $candidateUpper;
699                        $stateName      = $caStates[$candidateUpper];
700                        $stateSpanStart = $spanStart;
701                        break;
702                    } else if (($fullMatch = array_search($candidate, $usStates)) !== false) {
703                        $state          = $candidate;
704                        $stateCode      = $fullMatch;
705                        $stateName      = $candidate;
706                        $stateSpanStart = $spanStart;
707                        break;
708                    } else if (($fullMatch = array_search($candidate, $caStates)) !== false) {
709                        $state          = $candidate;
710                        $stateCode      = $fullMatch;
711                        $stateName      = $candidate;
712                        $stateSpanStart = $spanStart;
713                        break;
714                    }
715                }
716
717                if ($state !== null) {
718                    if (!in_array($stateLine, $linesProcessed)) {
719                        $linesProcessed[] = $stateLine;
720                    }
721
722                    // A comma already separated an earlier segment from this one if any lower
723                    // line index exists at all - in that case, whatever precedes the state
724                    // in THIS line's own tokens (or the nearest preceding line, if this line's
725                    // "before" is empty) is unambiguously city, because the street already got
726                    // its own segment earlier. Only when there's no preceding segment - a truly
727                    // comma-less single-line address - can this line's leading tokens be a
728                    // street/city hybrid that needs the route-type-boundary split below.
729                    $hasPrecedingLine = false;
730                    foreach ($lineKeys as $i) {
731                        if ($i < $stateLine) {
732                            $hasPrecedingLine = true;
733                            break;
734                        }
735                    }
736
737                    $routeTypes = array_merge(
738                        array_map('strtolower', $addressValues->getRouteTypes(true)),
739                        $addressValues->getCommonRouteTypes()
740                    );
741
742                    // Prefer the RIGHTMOST route-type match that still leaves at least
743                    // one token after it (for a city). Neither "first" nor "last" alone
744                    // works: taking the last match breaks when a city name itself ends in
745                    // a route-type word ("Beverly Hills" - "Hills" is a valid suffix), and
746                    // taking the first match breaks when the street name itself starts
747                    // with a route-type word ("Park Ave ...", "Circle Dr ..."). A match
748                    // with nothing after it is far more likely to be the tail of the city
749                    // name than the street's actual route suffix, since a route suffix is
750                    // normally followed by a city.
751                    $findRouteBoundary = function(array $span) use ($routeTypes): ?int {
752                        $routeEndIndex   = null;
753                        $lastSpanIndex   = count($span) - 1;
754                        foreach ($span as $idx => $word) {
755                            $routeCandidate = strtolower(rtrim($word, '.'));
756                            if (in_array($routeCandidate, $routeTypes, true) && ($idx < $lastSpanIndex)) {
757                                $routeEndIndex = $idx;
758                            }
759                        }
760                        return $routeEndIndex;
761                    };
762
763                    // City: remaining tokens before the state, in the same line
764                    $before = array_slice($tokens[$stateLine], 0, $stateSpanStart);
765                    if (!empty($before)) {
766                        if ($hasPrecedingLine) {
767                            // A comma already separates this from the street - it's just city.
768                            $city = implode(' ', $before);
769                        } else {
770                            // This line carries city AND (with no comma to separate them)
771                            // possibly the street portion too. Find where the street portion
772                            // ends (its route-type suffix, if any) so city only takes what's
773                            // left over, and hand the leading portion back for street parsing
774                            // rather than losing it. If no route-type boundary can be found,
775                            // city is left unguessed (null) rather than swallowing words that
776                            // might be street, not city.
777                            $routeEndIndex = $findRouteBoundary($before);
778
779                            if ($routeEndIndex !== null) {
780                                $city = implode(' ', array_slice($before, $routeEndIndex + 1));
781                                if ($city === '') {
782                                    $city = null;
783                                }
784                                $trimmedLines[$stateLine] = array_slice($before, 0, $routeEndIndex + 1);
785                            } else {
786                                $trimmedLines[$stateLine] = $before;
787                            }
788                        }
789                    } else {
790                        // Fall back to the nearest preceding unconsumed line. If that line
791                        // still looks like it carries the street (a route-type boundary can be
792                        // found in it, it starts with a number, or it's a PO Box line), don't
793                        // swallow it whole as city - split it the same way, or hand it back
794                        // unsplit for street parsing, rather than silently discarding the street.
795                        foreach ($lineKeys as $i) {
796                            if (($i < $stateLine) && !in_array($i, $linesProcessed)) {
797                                $candidateLine = $tokens[$i];
798                                $routeEndIndex = $findRouteBoundary($candidateLine);
799
800                                if ($routeEndIndex !== null) {
801                                    $city = implode(' ', array_slice($candidateLine, $routeEndIndex + 1));
802                                    if ($city === '') {
803                                        $city = null;
804                                    }
805                                    $trimmedLines[$i] = array_slice($candidateLine, 0, $routeEndIndex + 1);
806                                } else if ((preg_match('/^\d/', $candidateLine[0]) === 1) || $looksLikePoBoxLine($candidateLine)) {
807                                    $trimmedLines[$i] = $candidateLine;
808                                } else {
809                                    $city = implode(' ', $candidateLine);
810                                }
811
812                                $linesProcessed[] = $i;
813                                break;
814                            }
815                        }
816                    }
817                }
818            }
819        }
820
821        // Country is only recognized as a distinct, unconsumed segment (never the state slot).
822        // Bare two-letter codes ("US"/"CA") are deliberately excluded here - only unambiguous
823        // full forms are accepted - because a bare "CA" can only safely be trusted as a state
824        // when the state-slot mechanism above resolves it; without a postal code to anchor
825        // that slot, a bare "CA" segment must not silently become "Canada" instead.
826        $countryLineValues = [
827            'US' => ['USA', 'U S A', 'UNITED STATES'],
828            'CA' => ['CAN', 'CANADA'],
829        ];
830
831        foreach ($lineKeys as $i) {
832            if (in_array($i, $linesProcessed)) {
833                continue;
834            }
835
836            $normalizedLine = strtoupper(str_replace('.', '', implode(' ', $tokens[$i])));
837
838            if (in_array($normalizedLine, $countryLineValues['CA'], true)) {
839                $country          = 'CA';
840                $linesProcessed[] = $i;
841                break;
842            }
843
844            if (in_array($normalizedLine, $countryLineValues['US'], true)) {
845                $country          = 'US';
846                $linesProcessed[] = $i;
847                break;
848            }
849        }
850
851        return [
852            'city'           => $city,
853            'stateName'      => $stateName,
854            'stateCode'      => $stateCode,
855            'postalCode'     => $postalCode,
856            'zip4'           => $zip4,
857            'country'        => $country,
858            'linesProcessed' => $linesProcessed,
859            'trimmedLines'   => $trimmedLines,
860        ];
861    }
862
863    /**
864     * Extract street/location details (PO Box, unit, direction, route type, street number/name)
865     *
866     * Operates on whatever lines extractGeo() didn't consume. The primary (first) line is
867     * where the street number, name, route type and direction are extracted from; a
868     * secondary line is only pulled in if it looks like a unit (e.g. a comma-separated
869     * "Apt 3B" segment) so that an unrecognized trailing line (e.g. a city extractGeo()
870     * couldn't place) is never merged into the street name. Each step removes the tokens it
871     * claims before the next step runs, so nothing can be claimed twice.
872     *
873     * @param  array         $lines
874     * @param  AddressValues $addressValues
875     * @return array
876     */
877    protected function extractLocation(array $lines, AddressValues $addressValues): array
878    {
879        $lines = array_values($lines);
880
881        $streetNumber      = null;
882        $streetName        = null;
883        $routeType         = null;
884        $unit              = null;
885        $direction         = null;
886        $directionPosition = null;
887        $isPoBox           = false;
888
889        if (empty($lines)) {
890            return compact('streetNumber', 'streetName', 'routeType', 'direction', 'directionPosition', 'unit', 'isPoBox');
891        }
892
893        $unitTypes = array_map('strtoupper', $addressValues->getUnitTypes());
894        $routeTypes = array_merge(
895            array_map('strtolower', $addressValues->getRouteTypes(true)),
896            $addressValues->getCommonRouteTypes()
897        );
898        $poBoxRegex = '/^(P\.?O\.?\s*Box|POB|Box)$/i';
899
900        // Pick the primary (street) line: the first remaining line with STRONG evidence of
901        // being the street - a leading number AND a trailing route-type word together, or a
902        // match for the PO Box pattern. This matters when a non-street line sorts ahead of the
903        // real street line (e.g. a recipient name: "John Smith, 123 Main St, ..."); without
904        // it, the recipient name would be mistaken for the street name and the real street
905        // silently dropped. Requiring BOTH signals (not just one) matters just as much: a line
906        // that only weakly matches one signal - e.g. "4th Floor" starts with a digit but isn't
907        // a street - must not be promoted over the true street line just because that line
908        // (e.g. "Broadway") has no recognizable route-type suffix of its own. Falls back to
909        // the first line when nothing qualifies.
910        $primaryIndex = 0;
911        foreach ($lines as $idx => $line) {
912            $lastLineIndex = count($line) - 1;
913            $looksLikePoBox = (preg_match($poBoxRegex, str_replace(' ', '', $line[0])) === 1)
914                || ((count($line) >= 2) && (strcasecmp(str_replace('.', '', $line[0]), 'PO') === 0) && (strcasecmp($line[1], 'Box') === 0));
915            $looksLikeStreet = $looksLikePoBox
916                || ((preg_match('/^\d/', $line[0]) === 1) && (in_array(strtolower(rtrim($line[$lastLineIndex], '.')), $routeTypes, true)));
917            if ($looksLikeStreet) {
918                $primaryIndex = $idx;
919                break;
920            }
921        }
922
923        $secondaryLines = $lines;
924        unset($secondaryLines[$primaryIndex]);
925
926        // A trailing (non-primary) line is only pulled in as a unit if it looks like one: a
927        // recognized designator word co-occurring with a digit (e.g. "Apt 3B"), or a bare
928        // "#..." token. A designator word alone isn't enough - several unit-type words
929        // ("Front", "Rear", "Lobby", "Pier", "Side", "Fl", ...) are also ordinary English
930        // words that appear in real street names, so requiring a digit too is what keeps an
931        // unrecognized line like "FL" (a state, with no zip to anchor it) from being
932        // mistaken for a unit. Anything that doesn't qualify is left alone rather than
933        // merged into the street name.
934        foreach ($secondaryLines as $line) {
935            $hasDesignator = false;
936            $hasDigit      = false;
937            foreach ($line as $word) {
938                if (in_array(strtoupper(rtrim($word, '.')), $unitTypes, true)) {
939                    $hasDesignator = true;
940                }
941                if (preg_match('/\d/', $word) === 1) {
942                    $hasDigit = true;
943                }
944            }
945            if (($hasDesignator && $hasDigit) || str_starts_with($line[0], '#')) {
946                $unit = implode(' ', $line);
947                break;
948            }
949        }
950
951        $tokens = $lines[$primaryIndex];
952
953        // PO Box, e.g. "PO Box 1234", "P.O. Box 1234", "POB 1234", "Box 1234"
954        if ((count($tokens) >= 2) && (preg_match($poBoxRegex, str_replace(' ', '', $tokens[0])) === 1)
955            && (preg_match('/^\d+[A-Za-z]?$/', $tokens[1]) === 1)) {
956            return [
957                'streetNumber'      => null,
958                'streetName'        => 'PO Box ' . $tokens[1],
959                'routeType'         => null,
960                'direction'         => null,
961                'directionPosition' => null,
962                'unit'              => $unit,
963                'isPoBox'           => true,
964            ];
965        }
966        // "PO" "Box" "1234" as three separate tokens (e.g. from "P.O. Box 1234")
967        if ((count($tokens) >= 3) && (strcasecmp(str_replace('.', '', $tokens[0]), 'PO') === 0)
968            && (strcasecmp($tokens[1], 'Box') === 0) && (preg_match('/^\d+[A-Za-z]?$/', $tokens[2]) === 1)) {
969            return [
970                'streetNumber'      => null,
971                'streetName'        => 'PO Box ' . $tokens[2],
972                'routeType'         => null,
973                'direction'         => null,
974                'directionPosition' => null,
975                'unit'              => $unit,
976                'isPoBox'           => true,
977            ];
978        }
979
980        // Unit designator within the primary line: anchored to the tail (a bare "#..." last
981        // token, or a recognized designator word immediately followed by a value token that
982        // contains a digit). Anchoring here - rather than scanning the whole line - is what
983        // keeps a street name like "123 Front St" or "500 Pier Rd" from having its second
984        // word mistaken for a unit designator; "Front"/"Pier" are unit-type words too, but
985        // "St"/"Rd" right after them don't look like a unit value.
986        if ($unit === null) {
987            $lastIndex = count($tokens) - 1;
988            if (($lastIndex >= 0) && str_starts_with($tokens[$lastIndex], '#')) {
989                $unit = $tokens[$lastIndex];
990                array_splice($tokens, $lastIndex, 1);
991            } else if ($lastIndex >= 1) {
992                $designatorCandidate = strtoupper(rtrim($tokens[$lastIndex - 1], '.'));
993                if (in_array($designatorCandidate, $unitTypes, true) && (preg_match('/\d/', $tokens[$lastIndex]) === 1)) {
994                    $unit = $tokens[$lastIndex - 1] . ' ' . $tokens[$lastIndex];
995                    array_splice($tokens, $lastIndex - 1, 2);
996                }
997            }
998        }
999
1000        // Direction: recognized only as a prefix (immediately after the street number) or a
1001        // suffix (the very last remaining token) - never in the middle of the street name.
1002        $directionSet = [];
1003        foreach ($addressValues->getDirections() as $value) {
1004            $directionSet[strtoupper(trim($value))] = true;
1005        }
1006
1007        if (count($tokens) > 1) {
1008            $prefixCandidate = strtoupper(rtrim($tokens[1], '.'));
1009            if (isset($directionSet[$prefixCandidate])) {
1010                $direction         = $tokens[1];
1011                $directionPosition = 0;
1012                array_splice($tokens, 1, 1);
1013            }
1014        }
1015        if (($direction === null) && (count($tokens) > 1)) {
1016            $lastIndex       = count($tokens) - 1;
1017            $suffixCandidate = strtoupper(rtrim($tokens[$lastIndex], '.'));
1018            if (isset($directionSet[$suffixCandidate])) {
1019                $direction         = $tokens[$lastIndex];
1020                $directionPosition = 1;
1021                array_splice($tokens, $lastIndex, 1);
1022            }
1023        }
1024
1025        // Route type: only recognized as the last remaining token, not merely present
1026        // anywhere in the street name (this is what fixes e.g. "Park" in "Park Granada"
1027        // being mistaken for a route-type suffix).
1028        if (!empty($tokens)) {
1029            $lastIndex = count($tokens) - 1;
1030            $candidate = strtolower(rtrim($tokens[$lastIndex], '.'));
1031            if (in_array($candidate, $routeTypes, true)) {
1032                $routeType = $tokens[$lastIndex];
1033                array_splice($tokens, $lastIndex, 1);
1034            }
1035        }
1036
1037        // Street number / name
1038        if (!empty($tokens)) {
1039            if (preg_match('/^\d/', $tokens[0]) === 1) {
1040                $streetNumber = $tokens[0];
1041                $streetName   = implode(' ', array_slice($tokens, 1));
1042            } else {
1043                $streetName = implode(' ', $tokens);
1044            }
1045            if ($streetName === '') {
1046                $streetName = null;
1047            }
1048        }
1049
1050        return compact('streetNumber', 'streetName', 'routeType', 'direction', 'directionPosition', 'unit', 'isPoBox');
1051    }
1052
1053    /**
1054     * To string method
1055     *
1056     * @return string
1057     */
1058    public function __toString(): string
1059    {
1060        return $this->getFullAddress();
1061    }
1062
1063}