$provinceCodes * @return array */ public function inspect(string $path, array $provinceCodes): array { if (! is_file($path) || ! is_readable($path) || filesize($path) > 20 * 1024 * 1024) { throw new RuntimeException('CSV inacessível ou superior a 20 MiB.'); } $bytes = file_get_contents($path); if ($bytes === false || ! mb_check_encoding($bytes, 'UTF-8') || str_contains($bytes, "\0")) { throw new RuntimeException('O CSV deve ser UTF-8 sem bytes nulos.'); } // Parse the exact bytes hashed, avoiding a second read of a changing input. $stream = fopen('php://temp', 'w+'); if ($stream === false) { throw new RuntimeException('Não foi possível preparar a leitura.'); } fwrite($stream, preg_replace('/^\xEF\xBB\xBF/', '', $bytes) ?? $bytes); rewind($stream); try { if (fgetcsv($stream, null, ';', '"', '') !== self::HEADERS) { throw new RuntimeException('Cabeçalho inválido: usar o contrato INE com separador ponto e vírgula.'); } $errors = $seen = $names = $totals = []; $duplicates = $count = 0; while (($values = fgetcsv($stream, null, ';', '"', '')) !== false) { $count++; if ($count > 50000) { throw new RuntimeException('O CSV excede 50.000 registos.'); } $issues = []; if (count($values) !== count(self::HEADERS)) { $errors[] = ['record' => $count + 1, 'issues' => ['column_count']]; continue; } $row = array_combine(self::HEADERS, array_map(fn ($v) => trim((string) $v), $values)); foreach ($row as $field => $value) { if (mb_strlen($value) > 255 || preg_match('/[\x00-\x1F\x7F]/', $value)) { $issues[] = 'invalid_field:'.$field; } } if (! preg_match('/^[0-9]{2}$/D', $row['province_code']) || ! in_array($row['province_code'], $provinceCodes, true)) { $issues[] = 'unknown_province'; } foreach (['province', 'district', 'admin_post', 'locality'] as $kind) { $code = $row[$kind.'_code']; $name = $row[$kind.'_name']; if (($kind === 'province' || $kind === 'district' || $code !== '') && $name === '') { $issues[] = 'missing_name:'.$kind; } if (($kind === 'province' || $kind === 'district' || $name !== '') && $code === '') { $issues[] = 'missing_code:'.$kind; } if ($code === '') { continue; } if (! preg_match('/^[0-9]+$/D', $code)) { $issues[] = 'invalid_code:'.$kind; } $parent = match ($kind) { 'province' => 'MZ', 'district' => $row['province_code'], 'admin_post' => $row['district_code'], default => $row['admin_post_code'], }; $identity = $kind.':'.$code; $definition = [$parent, $name]; if ($kind === 'locality') { $definition = [...$definition, $row['locality_type'], $row['latitude'], $row['longitude']]; } if (isset($seen[$identity]) && $seen[$identity] !== $definition) { $issues[] = 'conflicting_code:'.$kind; } $seen[$identity] ??= $definition; $nameKey = $kind.':'.$parent.':'.mb_strtolower($name); if (isset($names[$nameKey]) && $names[$nameKey] !== $code) { $issues[] = 'ambiguous_name:'.$kind; } $names[$nameKey] ??= $code; $totals[$row['province_code']][$kind][$code] = true; } if ($row['locality_code'] !== '' && $row['admin_post_code'] === '') { $issues[] = 'locality_requires_reviewed_parent_exception'; } if (($row['latitude'] === '') !== ($row['longitude'] === '')) { $issues[] = 'incomplete_coordinates'; } foreach (['latitude' => 90, 'longitude' => 180] as $field => $bound) { if ($row[$field] !== '' && (! preg_match('/^-?[0-9]+(?:\.[0-9]+)?$/D', $row[$field]) || abs((float) $row[$field]) > $bound)) { $issues[] = 'invalid_coordinate:'.$field; } } if ($row['locality_code'] === '' && ($row['latitude'] !== '' || $row['longitude'] !== '' || $row['locality_type'] !== '')) { $issues[] = 'locality_metadata_without_locality'; } $fingerprint = hash('sha256', json_encode($row, JSON_THROW_ON_ERROR)); if (isset($seen['row:'.$fingerprint])) { $duplicates++; } $seen['row:'.$fingerprint] = []; if ($issues !== []) { $errors[] = ['record' => $count + 1, 'issues' => array_values(array_unique($issues))]; } } if ($count === 0) { $errors[] = ['record' => 1, 'issues' => ['empty_dataset']]; } ksort($totals); $counts = []; foreach ($totals as $province => $kinds) { $counts[$province] = array_map('count', $kinds); } return ['schema_version' => 1, 'sha256' => hash('sha256', $bytes), 'encoding' => 'UTF-8', 'delimiter' => ';', 'coordinate_crs' => 'EPSG:4326', 'records' => $count, 'duplicate_records' => $duplicates, 'rejected_records' => count($errors), 'status' => $errors === [] ? 'structurally_valid_requires_review' : 'rejected', 'candidate_counts_by_province' => $counts, 'errors' => $errors]; } finally { fclose($stream); } } }