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