import 'dart:convert'; import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart'; import 'package:uuid/uuid.dart'; const Uuid _uuid = Uuid(); class AiDigestParseResult { const AiDigestParseResult({required this.drafts, this.error}); final List drafts; final String? error; bool get success => error == null; } class AiDigestResponseParser { const AiDigestResponseParser({ this.maxItems = 10, this.maxTitleChars = 90, this.maxDetailsChars = 420, }); final int maxItems; final int maxTitleChars; final int maxDetailsChars; AiDigestParseResult parse({ required String response, required Map tokenToPersonId, required String sourceRunId, required DateTime suggestedAt, }) { try { final Object? decoded = jsonDecode(_extractJson(response)); final List items = _extractItems(decoded); final List drafts = []; for (final dynamic item in items.take(maxItems)) { if (item is! Map) { continue; } final String? token = _string(item['personToken']); final String? personId = token == null ? null : tokenToPersonId[token]; if (personId == null) { continue; } final AiSuggestionKind? kind = _kind(item['kind']); final String title = _trimAndCap( _string(item['title']) ?? '', maxTitleChars, ); if (kind == null || title.isEmpty) { continue; } drafts.add( AiSuggestionDraft( id: 'ai-${_uuid.v4()}', personId: personId, kind: kind, title: title, details: _trimAndCap( _string(item['details']) ?? '', maxDetailsChars, ), suggestedAt: suggestedAt, suggestedFor: _dateOrNull(item['suggestedFor']) ?? _dateOrNull(item['suggestedTiming']), eventEndsAt: _dateOrNull(item['eventEndsAt']) ?? _dateOrNull(item['endsAt']) ?? _dateOrNull(item['endTime']), eventLocation: _optionalTrimAndCap( _string(item['eventLocation']) ?? _string(item['location']), 160, ), eventUrl: _urlOrNull(item['eventUrl']) ?? _urlOrNull(item['url']), sourceUrls: _sourceUrls(item['sourceUrls'] ?? item['sources']), confidence: _confidence(item['confidence']), status: AiSuggestionStatus.pending, sourceRunId: sourceRunId, reason: _trimAndCap(_string(item['reason']) ?? '', 240), ), ); } if (drafts.isEmpty) { return const AiDigestParseResult( drafts: [], error: 'No valid digest suggestions returned.', ); } return AiDigestParseResult(drafts: drafts); } catch (error) { return AiDigestParseResult( drafts: const [], error: 'Unable to parse digest response: $error', ); } } String _extractJson(String response) { final String trimmed = response.trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { return trimmed; } final int objectStart = trimmed.indexOf('{'); final int arrayStart = trimmed.indexOf('['); final int start = objectStart == -1 ? arrayStart : arrayStart == -1 ? objectStart : objectStart < arrayStart ? objectStart : arrayStart; final int objectEnd = trimmed.lastIndexOf('}'); final int arrayEnd = trimmed.lastIndexOf(']'); final int end = objectEnd > arrayEnd ? objectEnd : arrayEnd; if (start < 0 || end < start) { throw const FormatException('No JSON object or array found.'); } return trimmed.substring(start, end + 1); } List _extractItems(Object? decoded) { if (decoded is List) { return decoded; } if (decoded is Map) { final Object? suggestions = decoded['suggestions'] ?? decoded['items']; if (suggestions is List) { return suggestions; } } throw const FormatException('Digest response must be a JSON array.'); } AiSuggestionKind? _kind(Object? raw) { final String? value = _string(raw); if (value == null) { return null; } for (final AiSuggestionKind kind in AiSuggestionKind.values) { if (kind.name == value) { return kind; } } return null; } String? _string(Object? raw) { if (raw == null) { return null; } final String value = '$raw'.trim(); return value.isEmpty ? null : value; } DateTime? _dateOrNull(Object? raw) { final String? value = _string(raw); if (value == null) { return null; } return DateTime.tryParse(value)?.toLocal(); } String? _urlOrNull(Object? raw) { final String? value = _string(raw); if (value == null) { return null; } final Uri? uri = Uri.tryParse(value); if (uri == null || !uri.hasScheme || uri.host.isEmpty) { return null; } return uri.toString(); } List _sourceUrls(Object? raw) { final List values = raw is List ? raw : [raw]; final Set urls = {}; for (final Object? value in values) { if (value is Map) { final String? url = _urlOrNull(value['url']) ?? _urlOrNull(value['uri']) ?? _urlOrNull(value['link']); if (url != null) { urls.add(url); } continue; } final String? url = _urlOrNull(value); if (url != null) { urls.add(url); } } return urls.take(5).toList(growable: false); } double _confidence(Object? raw) { if (raw is num) { return raw.toDouble().clamp(0.0, 1.0).toDouble(); } return 0.5; } String _trimAndCap(String value, int maxChars) { final String trimmed = value.trim(); if (trimmed.length <= maxChars) { return trimmed; } return trimmed.substring(0, maxChars).trim(); } String? _optionalTrimAndCap(String? value, int maxChars) { if (value == null) { return null; } final String trimmed = _trimAndCap(value, maxChars); return trimmed.isEmpty ? null : trimmed; } }