normalize($input); // Explicit prefixes remove the ambiguity between an AIC and a retail // barcode. A bare six/nine-digit value is also a deterministic AIC // candidate: those lengths are not retail EAN/UPC lengths, and // forcing the operator to type `AIC:` made the identifier path fail // in the default/auto domain. $explicit = strtoupper(trim($input)); if (preg_match('/^AIC(?:6|9)?(?:\s*[:\-]\s*|\s+)(\d{6}|\d{9})$/', $explicit, $matches) === 1) { $aic = $matches[1]; return new IdentifierResolution( MediaType::MEDICINE, strlen($aic) === 9 ? 'aic9' : 'aic6', $aic, IdentifierResolution::CONFIDENCE_HIGH, 'explicit AIC notation' ); } // Some Italian pharmacy systems print AIC values with a leading // letter (for example A028511095). Treat that notation as the same // six/nine-digit AIC used by the AIFA archive. if (preg_match('/^A(\d{6}|\d{9})$/', $explicit, $matches) === 1) { $aic = $matches[1]; return new IdentifierResolution( MediaType::MEDICINE, strlen($aic) === 9 ? 'aic9' : 'aic6', $aic, IdentifierResolution::CONFIDENCE_HIGH, 'A-prefixed AIC notation' ); } if (preg_match('/^ATC\s*:\s*([A-V]\d{2}(?:[A-Z]{1,2}(?:\d{2})?)?)$/', $explicit, $matches) === 1) { return new IdentifierResolution( MediaType::MEDICINE, 'atc', strtoupper($matches[1]), IdentifierResolution::CONFIDENCE_HIGH, 'explicit ATC prefix' ); } $hintNorm = MediaType::normalize($hint); if ($hintNorm === MediaType::MEDICINE) { if (preg_match(self::RE_AIC9, $normalized) === 1) { return new IdentifierResolution(MediaType::MEDICINE, 'aic9', $normalized, IdentifierResolution::CONFIDENCE_HIGH, 'operator hint: medicine'); } if (preg_match(self::RE_AIC6, $normalized) === 1) { return new IdentifierResolution(MediaType::MEDICINE, 'aic6', $normalized, IdentifierResolution::CONFIDENCE_HIGH, 'operator hint: medicine'); } if (preg_match(self::RE_ATC, $normalized) === 1) { return new IdentifierResolution(MediaType::MEDICINE, 'atc', $normalized, IdentifierResolution::CONFIDENCE_HIGH, 'operator hint: medicine'); } } // In AUTO, six/nine digits are AIC candidates because they are not // retail EAN/UPC lengths. A concrete non-medical operator hint keeps // precedence and continues through its normal identifier rules. if (in_array($hintNorm, [MediaType::AUTO, MediaType::UNKNOWN], true)) { if (preg_match(self::RE_AIC9, $normalized) === 1) { return new IdentifierResolution(MediaType::MEDICINE, 'aic9', $normalized, IdentifierResolution::CONFIDENCE_HIGH, 'bare AIC-9 identifier'); } if (preg_match(self::RE_AIC6, $normalized) === 1) { return new IdentifierResolution(MediaType::MEDICINE, 'aic6', $normalized, IdentifierResolution::CONFIDENCE_HIGH, 'bare AIC-6 identifier'); } } if (preg_match(self::RE_ATC, $normalized) === 1) { return new IdentifierResolution(MediaType::MEDICINE, 'atc', $normalized, IdentifierResolution::CONFIDENCE_HIGH); } // ---- Classe A: structured identifiers ---- if (preg_match(self::RE_MBID, $input)) { return new IdentifierResolution( MediaType::MUSIC, 'mbid', $input, IdentifierResolution::CONFIDENCE_HIGH ); } if (preg_match(self::RE_IMDB, $input)) { return new IdentifierResolution( MediaType::MOVIE, 'imdb_id', strtolower($input), IdentifierResolution::CONFIDENCE_HIGH ); } // Eight bare digits are also GTIN-8. Prefer ISSN only when the standard // hyphen is present, or when its checksum is valid and GTIN's is not. if (preg_match(self::RE_ISSN, $normalized) && (str_contains(trim($input), '-') || ($this->isIssnValid($normalized) && ! $this->isGtinValid($normalized)))) { $valid = $this->isIssnValid($normalized); return new IdentifierResolution( MediaType::BOOK, 'issn', $normalized, $valid ? IdentifierResolution::CONFIDENCE_HIGH : IdentifierResolution::CONFIDENCE_LOW, $valid ? null : 'ISSN checksum invalid' ); } if (preg_match(self::RE_ISBN13, $normalized)) { $valid = $this->isGtinValid($normalized); return new IdentifierResolution( MediaType::BOOK, 'isbn13', $normalized, $valid ? IdentifierResolution::CONFIDENCE_HIGH : IdentifierResolution::CONFIDENCE_LOW, $valid ? null : 'ISBN-13 checksum invalid' ); } if (preg_match(self::RE_ISBN10, $normalized)) { $valid = $this->isIsbn10Valid($normalized); // Invalid ISBN-like input stays normalized but is never promoted by // conversion to a seemingly valid, high-confidence ISBN-13. $isbn13 = $this->isbn10to13($normalized); return new IdentifierResolution( MediaType::BOOK, $valid ? 'isbn13' : 'isbn10', $valid ? $isbn13 : $normalized, $valid ? IdentifierResolution::CONFIDENCE_HIGH : IdentifierResolution::CONFIDENCE_LOW, $valid ? 'Converted from ISBN-10: ' . $normalized : 'ISBN-10 checksum invalid' ); } if (preg_match(self::RE_GTIN14, $normalized)) { $valid = $this->isGtinValid($normalized); return new IdentifierResolution( MediaType::GENERIC, 'gtin14', $normalized, $valid ? IdentifierResolution::CONFIDENCE_HIGH : IdentifierResolution::CONFIDENCE_LOW, $valid ? null : 'GTIN-14 checksum invalid' ); } if (preg_match(self::RE_EAN13, $normalized)) { return $this->classifyEan13($normalized, $hint); } if (preg_match(self::RE_UPCA, $normalized)) { return $this->classifyUpcA($normalized, $hint); } if (preg_match(self::RE_EAN8, $normalized)) { $valid = $this->isGtinValid($normalized); return new IdentifierResolution( MediaType::FOOD, 'ean8', $normalized, $valid ? IdentifierResolution::CONFIDENCE_HIGH : IdentifierResolution::CONFIDENCE_LOW, $valid ? null : 'GTIN-8 checksum invalid' ); } // ---- Classe B: contextual keywords ---- $lower = strtolower($input); if ($this->containsKeyword($lower, self::MUSIC_KEYWORDS)) { return new IdentifierResolution( MediaType::MUSIC, 'title_hint', $input, IdentifierResolution::CONFIDENCE_MEDIUM, 'music keyword detected' ); } if ($this->containsKeyword($lower, self::GAME_KEYWORDS)) { return new IdentifierResolution( MediaType::GAME, 'title_hint', $input, IdentifierResolution::CONFIDENCE_MEDIUM, 'game keyword detected' ); } if ($this->containsKeyword($lower, self::MOVIE_KEYWORDS)) { return new IdentifierResolution( MediaType::MOVIE, 'title_hint', $input, IdentifierResolution::CONFIDENCE_MEDIUM, 'movie keyword detected' ); } // Classe C: free text, no domain determinable return null; } // ------------------------------------------------------------------ private helpers private function normalize(string $input): string { return strtoupper(str_replace(['-', ' '], '', trim($input))); } /** * EAN-13 routing based on GS1 prefix: * 978/979 → Books * 200-299 → internal/variable-measure * default → Food/Generic */ private function classifyEan13(string $ean13, ?string $hint): IdentifierResolution { $valid = $this->isGtinValid($ean13); $prefix3 = substr($ean13, 0, 3); if ($prefix3 === '978' || $prefix3 === '979') { return new IdentifierResolution( MediaType::BOOK, 'isbn13', $ean13, $valid ? IdentifierResolution::CONFIDENCE_HIGH : IdentifierResolution::CONFIDENCE_LOW, $valid ? null : 'ISBN-13 checksum invalid' ); } // Hint from operator (UI domain selector) if ($hint !== null) { $hintNorm = MediaType::normalize($hint); if ($hintNorm !== MediaType::UNKNOWN && $hintNorm !== MediaType::AUTO) { return new IdentifierResolution( $hintNorm, 'ean13', $ean13, $valid ? IdentifierResolution::CONFIDENCE_HIGH : IdentifierResolution::CONFIDENCE_LOW, 'operator hint: ' . $hint . ($valid ? '' : '; EAN-13 checksum invalid') ); } } // Default: treat as food/generic product return new IdentifierResolution( MediaType::FOOD, 'ean13', $ean13, $valid ? IdentifierResolution::CONFIDENCE_MEDIUM : IdentifierResolution::CONFIDENCE_LOW, $valid ? null : 'EAN-13 checksum invalid' ); } /** * UPC-A (12 digits) — typically music CDs/vinyl or generic products. * Routing uses operator hint if present. */ private function classifyUpcA(string $upc, ?string $hint): IdentifierResolution { $valid = $this->isGtinValid($upc); if ($hint !== null) { $hintNorm = MediaType::normalize($hint); if ($hintNorm === MediaType::MUSIC) { return new IdentifierResolution( MediaType::MUSIC, 'upc', $upc, $valid ? IdentifierResolution::CONFIDENCE_HIGH : IdentifierResolution::CONFIDENCE_LOW, 'operator hint: music' . ($valid ? '' : '; UPC-A checksum invalid') ); } } return new IdentifierResolution( MediaType::GENERIC, 'upc', $upc, $valid ? IdentifierResolution::CONFIDENCE_MEDIUM : IdentifierResolution::CONFIDENCE_LOW, $valid ? null : 'UPC-A checksum invalid' ); } private function isGtinValid(string $value): bool { $sum = 0; for ($i = strlen($value) - 2, $position = 1; $i >= 0; $i--, $position++) { $sum += (int) $value[$i] * ($position % 2 === 1 ? 3 : 1); } return (10 - ($sum % 10)) % 10 === (int) $value[strlen($value) - 1]; } private function isIsbn10Valid(string $value): bool { $sum = 0; for ($i = 0; $i < 10; $i++) { $digit = strtoupper($value[$i]) === 'X' ? 10 : (int) $value[$i]; if ($digit === 10 && $i !== 9) { return false; } $sum += (10 - $i) * $digit; } return $sum % 11 === 0; } private function isIssnValid(string $value): bool { $value = str_replace('-', '', strtoupper($value)); $sum = 0; for ($i = 0; $i < 8; $i++) { $digit = $value[$i] === 'X' ? 10 : (int) $value[$i]; if ($digit === 10 && $i !== 7) { return false; } $sum += (8 - $i) * $digit; } return $sum % 11 === 0; } private function containsKeyword(string $lower, array $keywords): bool { foreach ($keywords as $kw) { if (str_contains($lower, $kw)) { return true; } } return false; } /** * Convert ISBN-10 to ISBN-13 (add 978 prefix, recompute check digit). */ private function isbn10to13(string $isbn10): string { $isbn10 = strtoupper($isbn10); $base = '978' . substr($isbn10, 0, 9); $sum = 0; for ($i = 0; $i < 12; $i++) { $d = (int) $base[$i]; $sum += ($i % 2 === 0) ? $d : $d * 3; } $check = (10 - ($sum % 10)) % 10; return $base . $check; } }