import 'package:flutter/foundation.dart'; import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:uuid/uuid.dart'; import '../../api/naver_api_client.dart'; import '../../api/naver/naver_local_search_api.dart'; import '../../../core/errors/network_exceptions.dart'; import '../../../domain/entities/restaurant.dart'; import 'naver_map_parser.dart'; import 'naver_url_processor.dart'; /// 네이버 검색 서비스 /// /// 네이버 지도 URL 파싱과 로컬 검색 API를 통합한 서비스입니다. class NaverSearchService { final NaverApiClient _apiClient; final NaverMapParser _mapParser; final NaverUrlProcessor _urlProcessor; final Uuid _uuid = const Uuid(); // 성능 최적화를 위한 정규식 캐싱 static final RegExp _nonAlphanumericRegex = RegExp(r'[^가-힣a-z0-9]'); NaverSearchService({ NaverApiClient? apiClient, NaverMapParser? mapParser, NaverUrlProcessor? urlProcessor, }) : _apiClient = apiClient ?? NaverApiClient(), _mapParser = mapParser ?? NaverMapParser(apiClient: apiClient), _urlProcessor = urlProcessor ?? NaverUrlProcessor(apiClient: apiClient, mapParser: mapParser); /// URL에서 식당 정보 가져오기 /// /// 네이버 지도 URL(단축 URL 포함)에서 식당 정보를 추출합니다. /// /// [url] 네이버 지도 URL 또는 단축 URL /// /// Throws: /// - [NaverMapParseException] URL 파싱 실패 시 /// - [NetworkException] 네트워크 오류 발생 시 Future getRestaurantFromUrl(String url) async { try { return await _urlProcessor.processUrl(url); } catch (e) { if (e is NaverMapParseException || e is NetworkException) { rethrow; } throw ParseException(message: '식당 정보를 가져올 수 없습니다: $e', originalError: e); } } /// 키워드로 주변 식당 검색 /// /// 검색어와 현재 위치를 기반으로 주변 식당을 검색합니다. Future> searchNearbyRestaurants({ required String query, double? latitude, double? longitude, int maxResults = 20, String sort = 'random', // random, comment }) async { try { final searchResults = await _apiClient.searchLocal( query: query, latitude: latitude, longitude: longitude, display: maxResults, sort: sort, ); return searchResults .map((result) => result.toRestaurant(id: _uuid.v4())) .toList(); } catch (e) { if (e is NetworkException) { rethrow; } throw ParseException(message: '식당 검색에 실패했습니다: $e', originalError: e); } } /// 식당 이름으로 상세 정보 검색 /// /// 식당 이름과 위치를 기반으로 더 자세한 정보를 검색합니다. Future searchRestaurantDetails({ required String name, String? address, double? latitude, double? longitude, }) async { try { // 검색어 구성 String query = name; if (address != null && address.isNotEmpty) { // 주소에서 시/구 정보 추출 final addressParts = address.split(' '); if (addressParts.length >= 2) { query = '${addressParts[0]} ${addressParts[1]} $name'; } } final searchResults = await _apiClient.searchLocal( query: query, latitude: latitude, longitude: longitude, display: 5, sort: 'comment', // 상세 검색 시 리뷰가 많은 곳 우선 ); if (searchResults.isEmpty) { return null; } // 가장 유사한 결과 찾기 (주소가 없으면 거리 기반 선택 포함) final bestMatch = _findBestMatch( name, searchResults, latitude: latitude, longitude: longitude, address: address, ); if (bestMatch != null) { final restaurant = bestMatch.toRestaurant(id: _uuid.v4()); // 네이버 지도 URL이 있으면 상세 정보 파싱 시도 if (restaurant.naverUrl != null) { try { final detailedRestaurant = await _mapParser.parseRestaurantFromUrl( restaurant.naverUrl!, ); // 기존 정보와 병합 return Restaurant( id: restaurant.id, name: restaurant.name, category: restaurant.category, subCategory: restaurant.subCategory, description: detailedRestaurant.description ?? restaurant.description, phoneNumber: restaurant.phoneNumber, roadAddress: restaurant.roadAddress, jibunAddress: restaurant.jibunAddress, latitude: restaurant.latitude, longitude: restaurant.longitude, lastVisitDate: restaurant.lastVisitDate, source: restaurant.source, createdAt: restaurant.createdAt, updatedAt: DateTime.now(), naverPlaceId: detailedRestaurant.naverPlaceId ?? restaurant.naverPlaceId, naverUrl: restaurant.naverUrl, businessHours: detailedRestaurant.businessHours ?? restaurant.businessHours, lastVisited: restaurant.lastVisited, visitCount: restaurant.visitCount, ); } catch (e) { // 상세 파싱 실패해도 기본 정보 반환 AppLogger.debug( '[NaverSearchService] 상세 정보 파싱 실패: ${e.toString()}', ); } } return restaurant; } return null; } catch (e) { if (e is NetworkException) { rethrow; } throw ParseException( message: '식당 상세 정보 검색에 실패했습니다: $e', originalError: e, ); } } /// 가장 유사한 검색 결과 찾기 NaverLocalSearchResult? _findBestMatch( String targetName, List results, { double? latitude, double? longitude, String? address, }) { if (results.isEmpty) return null; // 정확히 일치하는 결과 우선 final exactMatch = results.firstWhere( (result) => result.title.toLowerCase() == targetName.toLowerCase(), orElse: () => results.first, ); if (exactMatch.title.toLowerCase() == targetName.toLowerCase()) { return exactMatch; } // 주소가 없고 위치 정보가 있는 경우 - 가장 가까운 업체 선택 // TODO: 네이버 좌표계(mapx, mapy)를 WGS84 좌표계로 변환하는 로직 필요 // 현재는 네이버 API가 좌표 기반 정렬을 지원하므로 첫 번째 결과 사용 if ((address == null || address.isEmpty) && latitude != null && longitude != null) { // 네이버 API는 coordinate 파라미터로 좌표 기반 정렬을 지원 // searchRestaurants에서 이미 가까운 순으로 정렬되어 반환됨 return results.first; } // 유사도 계산 (간단한 버전) NaverLocalSearchResult? bestMatch; double bestScore = 0.0; for (final result in results) { final score = _calculateSimilarity(targetName, result.title); if (score > bestScore) { bestScore = score; bestMatch = result; } } // 유사도가 너무 낮으면 null 반환 if (bestScore < 0.5) { return null; } return bestMatch ?? results.first; } /// 문자열 유사도 계산 (Jaccard 유사도) double _calculateSimilarity(String str1, String str2) { final s1 = str1.toLowerCase().replaceAll(_nonAlphanumericRegex, ''); final s2 = str2.toLowerCase().replaceAll(_nonAlphanumericRegex, ''); if (s1.isEmpty || s2.isEmpty) return 0.0; // 포함 관계 확인 if (s1.contains(s2) || s2.contains(s1)) { return 0.8; } // 문자 집합으로 변환 final set1 = s1.split('').toSet(); final set2 = s2.split('').toSet(); // Jaccard 유사도 계산 final intersection = set1.intersection(set2).length; final union = set1.union(set2).length; return union > 0 ? intersection / union : 0.0; } /// 리소스 정리 void dispose() { _apiClient.dispose(); _mapParser.dispose(); } // 테스트를 위한 내부 메서드 접근 @visibleForTesting NaverLocalSearchResult? findBestMatchForTesting( String targetName, List results, { double? latitude, double? longitude, String? address, }) { return _findBestMatch( targetName, results, latitude: latitude, longitude: longitude, address: address, ); } @visibleForTesting double calculateSimilarityForTesting(String str1, String str2) { return _calculateSimilarity(str1, str2); } }