import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; /// Resolved motorcycle image metadata used by every motorcycle UI surface. class MotorcycleImage { final String? assetPath; final String? imageUrl; final String? sourceUrl; final String? credit; final String? license; final String caption; final String attribution; final bool isPhoto; const MotorcycleImage({ this.assetPath, this.imageUrl, this.sourceUrl, this.credit, this.license, this.caption = 'Motorcycle Technical Reference', this.attribution = 'Technical Schematic', this.isPhoto = false, }); bool get hasImage => assetPath != null || imageUrl != null; } /// Optional curated image descriptor for a motorcycle make/model/year range. class MotorcycleImageDescriptor { final String make; final String model; final int? exactYear; final int? minYear; final int? maxYear; final List modelAliases; final String assetPath; final String caption; final String attribution; final bool isPhoto; const MotorcycleImageDescriptor({ required this.make, required this.model, this.exactYear, this.minYear, this.maxYear, this.modelAliases = const [], required this.assetPath, required this.caption, this.attribution = 'Factory Technical Reference', this.isPhoto = true, }); static String normalize(String input) => input.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); bool matches(String targetMake, String targetModel, int? targetYear) { if (normalize(make) != normalize(targetMake)) return false; final normalizedTargetModel = normalize(targetModel); final modelMatches = normalize(model) == normalizedTargetModel || modelAliases.any((alias) => normalize(alias) == normalizedTargetModel); if (!modelMatches) return false; if (exactYear != null) return targetYear == exactYear; if (minYear != null || maxYear != null) { if (targetYear == null) return false; if (minYear != null && targetYear < minYear!) return false; if (maxYear != null && targetYear > maxYear!) return false; } return true; } MotorcycleImage toImage() => MotorcycleImage( assetPath: assetPath, caption: caption, attribution: attribution, isPhoto: isPhoto, ); } /// Single authoritative resolver for motorcycle imagery throughout MotorSignal. /// /// Resolution order: /// 1. Curated exact-year image /// 2. Curated generation/year-range image /// 3. Curated model image /// 4. Wikimedia Commons photograph with compatible licensing /// 5. Technical-reference fallback with no stale motorcycle imagery class MotorcycleImageService { MotorcycleImageService._(); static final MotorcycleImageService instance = MotorcycleImageService._(); static const List bundledPhotographicAssets = [ 'assets/images/motorcycles/yamaha_yzf_r6_2001.jpg', ]; static const List _defaultDescriptors = [ MotorcycleImageDescriptor( make: 'Yamaha', model: 'YZF-R6', minYear: 1999, maxYear: 2002, modelAliases: ['R6', 'YZFR6', 'YZF R6'], assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.jpg', caption: '2001 Yamaha YZF-R6 (5EB)', attribution: 'James McCauley / Wikimedia Commons, CC BY 2.0', isPhoto: true, ), ]; static final List _customDescriptors = []; final Map _cache = {}; static void registerDescriptor(MotorcycleImageDescriptor descriptor) { _customDescriptors.add(descriptor); instance._cache.clear(); } static void resetRegistry() { _customDescriptors.clear(); instance._cache.clear(); } List get allDescriptors => [ ..._customDescriptors, ..._defaultDescriptors, ]; MotorcycleImage? getCachedOrCuratedImage({ required int year, required String make, required String model, }) { final key = _key(year, make, model); if (_cache.containsKey(key)) return _cache[key]; return _resolveCurated(make: make, model: model, year: year); } Future resolve({ required int year, required String make, required String model, }) async { final key = _key(year, make, model); if (_cache.containsKey(key)) return _cache[key]; final curated = _resolveCurated(make: make, model: model, year: year); if (curated != null) { _cache[key] = curated; return curated; } try { // Cascading query strategy: // 1. Exact make + model + year // 2. Broad make + model final queries = ['$make $model $year', '$make $model']; for (final query in queries) { final image = await _searchWikimedia(query, year, make, model); if (image != null) { _cache[key] = image; return image; } } _cache[key] = null; return null; } catch (error) { debugPrint('Motorcycle image lookup failed: $error'); _cache[key] = null; return null; } } Future _searchWikimedia( String query, int year, String make, String model, ) async { try { final uri = Uri.https('commons.wikimedia.org', '/w/api.php', { 'action': 'query', 'format': 'json', 'origin': '*', 'generator': 'search', 'gsrnamespace': '6', 'gsrsearch': query, 'gsrlimit': '8', 'prop': 'imageinfo', 'iiprop': 'url|extmetadata', 'iiurlwidth': '900', 'iiextmetadatafilter': 'Artist|Credit|LicenseShortName|LicenseUrl', }); final response = await http .get( uri, headers: const { 'Accept': 'application/json', 'User-Agent': 'MotorSignal/1.0 (motorcycle garage image lookup)', }, ) .timeout(const Duration(seconds: 6)); if (response.statusCode != 200) return null; final json = jsonDecode(response.body) as Map; final pages = (json['query']?['pages'] as Map?)?.values; if (pages == null || pages.isEmpty) return null; MotorcycleImage? best; var bestScore = -1; final normalizedModel = MotorcycleImageDescriptor.normalize(model); final normalizedMake = MotorcycleImageDescriptor.normalize(make); final normalizedYear = year.toString(); for (final rawPage in pages) { final page = Map.from(rawPage as Map); final title = (page['title']?.toString() ?? '').toLowerCase(); final normalizedTitle = title.replaceAll(RegExp(r'[^a-z0-9]'), ''); final infoList = page['imageinfo']; if (infoList is! List || infoList.isEmpty) continue; final info = Map.from(infoList.first as Map); final thumb = info['thumburl']?.toString() ?? info['url']?.toString(); final source = info['descriptionurl']?.toString(); if (thumb == null || source == null) continue; final metadata = Map.from( (info['extmetadata'] as Map?) ?? const {}, ); final license = _metadataValue(metadata, 'LicenseShortName'); final licenseLower = (license ?? '').toLowerCase(); final allowed = licenseLower.contains('cc by') || licenseLower.contains('public domain') || licenseLower.contains('cc0'); if (!allowed) continue; var score = 0; if (normalizedTitle.contains(normalizedModel)) score += 5; if (normalizedTitle.contains(normalizedMake)) score += 3; if (title.contains(normalizedYear)) score += 4; if (title.contains('motorcycle') || title.contains('motorbike')) { score += 1; } if (score >= 4 && score > bestScore) { bestScore = score; best = MotorcycleImage( imageUrl: thumb, sourceUrl: source, credit: _metadataValue(metadata, 'Artist') ?? _metadataValue(metadata, 'Credit'), license: license, caption: '$year $make $model', attribution: 'Wikimedia Commons', isPhoto: true, ); } } return best; } catch (_) { return null; } } /// Synchronous curated/fallback lookup for screens that should never wait on /// network resolution. This is the same authoritative hierarchy used by UI. MotorcycleImage getReferenceImage({ required String? make, required String? model, int? year, }) { if (make == null || model == null || make.trim().isEmpty || model.trim().isEmpty) { return const MotorcycleImage(); } final curated = _resolveCurated(make: make, model: model, year: year); if (curated != null) return curated; final yearPrefix = year != null && year > 1900 ? '$year ' : ''; return MotorcycleImage( caption: '$yearPrefix${make.trim()} ${model.trim()}'.trim(), attribution: 'Technical Schematic', isPhoto: false, ); } bool hasPhotographicAsset({ required String? make, required String? model, int? year, }) => getReferenceImage(make: make, model: model, year: year).isPhoto; MotorcycleImage? _resolveCurated({ required String make, required String model, required int? year, }) { if (year != null) { for (final descriptor in allDescriptors) { if (descriptor.exactYear != null && descriptor.matches(make, model, year)) { return descriptor.toImage(); } } for (final descriptor in allDescriptors) { if ((descriptor.minYear != null || descriptor.maxYear != null) && descriptor.matches(make, model, year)) { return descriptor.toImage(); } } } for (final descriptor in allDescriptors) { if (descriptor.exactYear == null && descriptor.minYear == null && descriptor.maxYear == null && descriptor.matches(make, model, year)) { return descriptor.toImage(); } } return null; } String _key(int year, String make, String model) => '$year|${MotorcycleImageDescriptor.normalize(make)}|${MotorcycleImageDescriptor.normalize(model)}'; String? _metadataValue(Map metadata, String key) { final value = metadata[key]; if (value is Map) return value['value']?.toString(); return value?.toString(); } }