import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_client.dart'; import '../../../core/api/api_paths.dart'; import '../../../core/api/guarded_api_call.dart'; import '../../auth/data/auth_providers.dart'; import '../domain/admin_ai_categorize_result.dart'; import '../domain/admin_category_node.dart'; import '../domain/admin_pantry_item.dart'; import '../domain/admin_inventory_item.dart'; import '../domain/admin_product.dart'; import '../domain/ai_model_info.dart'; import '../domain/pending_product.dart'; import '../domain/receipt_alias.dart'; import '../domain/user_admin.dart'; final adminRepositoryProvider = Provider((ref) { return AdminRepository(ref.watch(apiClientProvider), ref); }); class AdminRepository { final ApiClient _apiClient; final Ref _ref; List? _selectableProductsCache; DateTime? _selectableProductsCacheAt; static const Duration _selectableProductsCacheTtl = Duration(seconds: 45); AdminRepository(this._apiClient, this._ref); void _invalidateSelectableProductsCache() { _selectableProductsCache = null; _selectableProductsCacheAt = null; } // ── Interna helpers ──────────────────────────────────────────────────────── Future _token() => _ref.read(authStateProvider.future); /// GET-anrop som returnerar en typad lista med [fromJson]. Future> _getList( String path, T Function(Map) fromJson, { bool requiresAuth = true, }) async { final token = requiresAuth ? await _token() : null; final data = await guardedApiCall( _ref, () => _apiClient.getJson(path, token: token), ); return _parseList(data, fromJson); } /// POST-anrop som returnerar ett typat objekt med [fromJson]. Future _post( String path, { required Map? body, required T Function(dynamic) parse, bool requiresAuth = true, }) async { final token = requiresAuth ? await _token() : null; final data = await guardedApiCall( _ref, () => _apiClient.postJson(path, body: body, token: token), ); return parse(data); } /// PATCH-anrop som returnerar ett typat objekt med [fromJson]. Future _patch( String path, { required Map body, required T Function(Map) parse, }) async { final token = await _token(); final data = await guardedApiCall( _ref, () => _apiClient.patchJson(path, body: body, token: token), ); return parse(Map.from(data as Map)); } /// Fire-and-forget PATCH. Future _patchVoid(String path, Map body) async { final token = await _token(); await guardedApiCall( _ref, () => _apiClient.patchJson(path, body: body, token: token), ); } /// Fire-and-forget POST. Future _postVoid(String path, [Map? body]) async { final token = await _token(); await guardedApiCall( _ref, () => _apiClient.postJson(path, body: body, token: token), ); } /// Fire-and-forget DELETE. Future _deleteVoid(String path) async { final token = await _token(); await guardedApiCall( _ref, () => _apiClient.deleteJson(path, token: token), ); } Future> _getMap( String path, { bool requiresAuth = true, }) async { final token = requiresAuth ? await _token() : null; final data = await guardedApiCall( _ref, () => _apiClient.getJson(path, token: token), ); return Map.from(data as Map); } Future> _postMap( String path, { Map? body, bool requiresAuth = true, }) async { final token = requiresAuth ? await _token() : null; final data = await guardedApiCall( _ref, () => _apiClient.postJson(path, body: body, token: token), ); return Map.from(data as Map); } /// Tolerant listparsning — accepterar ren lista eller wrapper ({items, data}). static List _parseList( dynamic data, T Function(Map) fromJson, ) { final List raw; if (data is List) { raw = data; } else if (data is Map) { raw = (data['items'] as List?) ?? (data['data'] as List?) ?? const []; if (raw.isEmpty && data.isNotEmpty) { debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}'); } } else { raw = const []; } return raw .whereType() .map((e) => fromJson(Map.from(e))) .toList(); } // ── Användare ────────────────────────────────────────────────────────────── Future> listUsers() => _getList(UserApiPaths.list, UserAdmin.fromJson); Future setRole(int userId, String newRole) => _patch(UserApiPaths.setRole(userId), body: {'role': newRole}, parse: UserAdmin.fromJson); Future setPremium(int userId, {required bool isPremium}) => _patch(UserApiPaths.setPremium(userId), body: {'isPremium': isPremium}, parse: UserAdmin.fromJson); Future setRecipeSharing(int userId, {required bool canShareRecipes}) => _patch(UserApiPaths.setRecipeSharing(userId), body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson); Future updateEmail(int userId, String email) => _patchVoid(UserApiPaths.updateEmail(userId), {'email': email}); Future createUser({ required String username, required String email, required String password, String role = 'user', }) => _post( UserApiPaths.list, body: { 'username': username, 'email': email, 'password': password, 'role': role, }, parse: (d) => UserAdmin.fromJson(d as Map), ); Future deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId)); /// Returns `{ temporaryPassword, to, subject, body }`. Future> resetPassword(int userId) => _postMap(UserApiPaths.resetPassword(userId), body: null); // ── Produkter ────────────────────────────────────────────────────────────── Future> listProducts() => _getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false); @Deprecated('Use listProducts(). Kept for temporary compatibility.') Future> listGlobalProducts() => listProducts(); Future> listPrivateProducts() => _getList(ProductApiPaths.privateList, PendingProduct.fromJson); Future> listSelectableProductsForAdmin({ bool forceRefresh = false, }) async { final now = DateTime.now(); final cached = _selectableProductsCache; final cacheAt = _selectableProductsCacheAt; if (!forceRefresh && cached != null && cacheAt != null) { if (now.difference(cacheAt) <= _selectableProductsCacheTtl) { return List.from(cached); } } final results = await Future.wait([ listProducts(), listPrivateProducts(), ]); final globalProducts = results[0] as List; final privateProducts = results[1] as List; final merged = { for (final product in globalProducts) product.id: product, }; for (final product in privateProducts) { merged[product.id] = AdminProduct( id: product.id, name: product.name, canonicalName: product.canonicalName, ownerId: product.ownerId, categoryId: product.categoryId, categoryPath: product.categoryPath, status: 'private', ); } final list = merged.values.toList(); list.sort( (a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), ); _selectableProductsCache = List.from(list); _selectableProductsCacheAt = now; return list; } Future> listDeletedProducts() => _getList(ProductApiPaths.deleted, AdminProduct.fromJson); Future> listPendingProducts() => _getList(ProductApiPaths.pending, PendingProduct.fromJson); Future setProductStatus(int productId, String status) => _patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) { _invalidateSelectableProductsCache(); }); Future promotePrivateProduct(int productId) => _post( ProductApiPaths.promotePrivate(productId), body: null, parse: (d) => AdminProduct.fromJson(Map.from(d as Map)), ).then((value) { _invalidateSelectableProductsCache(); return value; }); Future setProductCategory(int productId, {required int? categoryId}) => _patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) { _invalidateSelectableProductsCache(); }); Future removeProduct(int productId) => _deleteVoid(ProductApiPaths.remove(productId)).then((_) { _invalidateSelectableProductsCache(); }); Future restoreProduct(int productId) => _postVoid(ProductApiPaths.restore(productId)).then((_) { _invalidateSelectableProductsCache(); }); // ── Product canonical name updates ──────────────────────────────────────── Future updateCanonicalName(int productId, String canonicalName) => _patchVoid( ProductApiPaths.canonicalName(productId), {'canonicalName': canonicalName.trim()}, ).then((_) { _invalidateSelectableProductsCache(); }); Future updateCanonicalNamePrivate(int productId, String canonicalName) => _patchVoid( ProductApiPaths.canonicalNamePrivate(productId), {'canonicalName': canonicalName.trim()}, ).then((_) { _invalidateSelectableProductsCache(); }); // ── Product merging ──────────────────────────────────────────────────────── Future mergeProductsPrivate({ required int sourceProductId, required int targetProductId, }) => _postVoid(ProductApiPaths.mergePrivate, { 'sourceProductId': sourceProductId, 'targetProductId': targetProductId, }).then((_) { _invalidateSelectableProductsCache(); }); /// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`. Future> createProduct(String name, {int? categoryId}) => _postMap( ProductApiPaths.list, body: { 'name': name.trim(), if (categoryId != null) 'categoryId': categoryId, }, ).then((value) { _invalidateSelectableProductsCache(); return value; }); int _parseUpdatedCount(dynamic data) { if (data is! Map) { debugPrint('[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}'); return 0; } final map = Map.from(data); return (map['updated'] as num?)?.toInt() ?? 0; } Future bulkSetCategory(List ids, {required int? categoryId}) => _post( ProductApiPaths.bulkUpdate, body: {'ids': ids, 'categoryId': categoryId}, parse: _parseUpdatedCount, ).then((value) { _invalidateSelectableProductsCache(); return value; }); Future mergeProducts({ required int sourceProductId, required int targetProductId, }) => _postVoid(ProductApiPaths.merge, { 'sourceProductId': sourceProductId, 'targetProductId': targetProductId, }).then((_) { _invalidateSelectableProductsCache(); }); Future> previewMerge({ required int sourceProductId, required int targetProductId, }) => _getMap(ProductApiPaths.mergePreview(sourceProductId, targetProductId)); Future> aiCategorizeBulk({ List? productIds, }) async { final token = await _token(); final data = await guardedApiCall( _ref, () => _apiClient.postJson( ProductApiPaths.aiCategorizeBulk, body: (productIds == null || productIds.isEmpty) ? null : {'productIds': productIds}, token: token, ), ); return _parseList(data, AdminAiCategorizeResult.fromJson) .where((e) => e.productId > 0 && e.categoryId > 0) .toList(); } // ── Kategorier ───────────────────────────────────────────────────────────── Future> listCategoryTree() => _getList( CategoryApiPaths.tree, AdminCategoryNode.fromJson, requiresAuth: false, ); // ── AI-modeller ──────────────────────────────────────────────────────────── /// OBS: endpointen /ai/models kräver autentisering. Future> listAiModels() => _getList(AiApiPaths.models, AiModelInfo.fromJson); // ── Kvittoalias (admin/global fallback) ─────────────────────────────────── Future> listReceiptAliases() => _getList(ReceiptAliasApiPaths.list, ReceiptAlias.fromJson); Future upsertReceiptAlias({ required String receiptName, required int productId, bool isGlobal = false, }) => _postVoid(ReceiptAliasApiPaths.list, { 'receiptName': receiptName, 'productId': productId, 'isGlobal': isGlobal, }); Future removeReceiptAlias(int id) => _deleteVoid(ReceiptAliasApiPaths.remove(id)); // ── Admin inventory (global tabellhantering) ───────────────────────────── Future> listAdminInventory({ int? userId, String? sort, }) { final path = AdminInventoryApiPaths.withFilters(userId: userId, sort: sort); return _getList(path, AdminInventoryItem.fromJson); } Future createAdminInventory({ int? userId, required int productId, required double quantity, required String unit, String? location, String? brand, String? receiptName, String? suitableFor, String? comment, }) { return _post( AdminInventoryApiPaths.list, body: { if (userId != null) 'userId': userId, 'productId': productId, 'quantity': quantity, 'unit': unit, if (location != null && location.trim().isNotEmpty) 'location': location.trim(), if (brand != null && brand.trim().isNotEmpty) 'brand': brand.trim(), if (receiptName != null && receiptName.trim().isNotEmpty) 'receiptName': receiptName.trim(), if (suitableFor != null && suitableFor.trim().isNotEmpty) 'suitableFor': suitableFor.trim(), if (comment != null && comment.trim().isNotEmpty) 'comment': comment.trim(), }, parse: (d) => AdminInventoryItem.fromJson(Map.from(d as Map)), ); } Future updateAdminInventory( int inventoryId, { int? productId, double? quantity, String? unit, String? location, String? brand, String? receiptName, String? suitableFor, String? comment, }) { final body = { if (productId != null) 'productId': productId, if (quantity != null) 'quantity': quantity, if (unit != null) 'unit': unit, if (location != null) 'location': location, if (brand != null) 'brand': brand, if (receiptName != null) 'receiptName': receiptName, if (suitableFor != null) 'suitableFor': suitableFor, if (comment != null) 'comment': comment, }; return _patch( AdminInventoryApiPaths.update(inventoryId), body: body, parse: AdminInventoryItem.fromJson, ); } Future removeAdminInventory(int inventoryId) => _deleteVoid(AdminInventoryApiPaths.remove(inventoryId)); Future moveAdminInventoryToPantry(int inventoryId) => _postVoid(AdminInventoryApiPaths.moveToPantry(inventoryId)); // ── Admin pantry ────────────────────────────────────────────────────────── Future> listAdminPantry({int? userId}) { final params = {}; if (userId != null) params['userId'] = '$userId'; final path = params.isEmpty ? PantryApiPaths.adminList : '${PantryApiPaths.adminList}?${params.entries.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}').join('&')}'; return _getList(path, AdminPantryItem.fromJson); } Future removeAdminPantryItem(int pantryItemId) => _deleteVoid(PantryApiPaths.adminRemove(pantryItemId)); Future createAdminPantry({ int? userId, required int productId, String? location, }) { return _post( PantryApiPaths.adminCreate, body: { if (userId != null) 'userId': userId, 'productId': productId, if (location != null && location.trim().isNotEmpty) 'location': location.trim(), }, parse: (d) => AdminPantryItem.fromJson(Map.from(d as Map)), ); } Future updateAdminPantry( int pantryItemId, { int? productId, String? location, }) { return _patch( PantryApiPaths.adminUpdate(pantryItemId), body: { if (productId != null) 'productId': productId, if (location != null) 'location': location, }, parse: AdminPantryItem.fromJson, ); } Future moveAdminPantryToInventory( int pantryItemId, Map body, ) => _postVoid(PantryApiPaths.moveToInventoryAdmin(pantryItemId), body); Future mergeAdminInventory({ required int sourceInventoryId, required int targetInventoryId, }) => _postVoid(AdminInventoryApiPaths.merge, { 'sourceInventoryId': sourceInventoryId, 'targetInventoryId': targetInventoryId, }); Future> previewAdminInventoryMerge({ required int sourceInventoryId, required int targetInventoryId, }) => _getMap( AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId), ); }