import 'dart:async'; import 'package:grpc/grpc.dart'; import '../generated/delivery_service.pbgrpc.dart'; import '../models/delivery_route.dart'; import '../services/auth_service.dart'; import 'grpc_config.dart'; import 'types.dart'; // ignore_for_file: unused_element /// gRPC-based CQRS API client for Plan B Logistics. /// /// This client wraps the generated DeliveryServiceClient with `Result` error /// handling patterns consistent with [CqrsApiClient]. It provides: /// - Lazy channel initialization /// - Authentication via Bearer token in gRPC metadata /// - Automatic token refresh on UNAUTHENTICATED errors /// - Proper gRPC error to [ApiError] mapping /// /// Usage: /// ```dart /// final client = GrpcCqrsApiClient( /// config: GrpcConfig.development, /// authService: authService, /// ); /// /// final result = await client.getDeliveryRoutes(); /// result.when( /// success: (routes) => handleRoutes(routes), /// onError: (error) => handleError(error), /// ); /// ``` class GrpcCqrsApiClient { final GrpcConfig config; final AuthService? authService; ClientChannel? _channel; DeliveryServiceClient? _deliveryClient; GrpcCqrsApiClient({ required this.config, this.authService, }); /// Returns the gRPC channel, creating it lazily if needed. /// /// The channel is configured based on [config.useTls] for development /// (insecure) vs production (TLS) environments. ClientChannel get channel { if (_channel == null) { final credentials = config.useTls ? const ChannelCredentials.secure() : const ChannelCredentials.insecure(); _channel = ClientChannel( config.host, port: config.port, options: ChannelOptions( credentials: credentials, connectionTimeout: config.timeout, idleTimeout: const Duration(minutes: 5), ), ); } return _channel!; } /// Returns the DeliveryService client, creating it lazily if needed. DeliveryServiceClient get deliveryClient { _deliveryClient ??= DeliveryServiceClient(channel); return _deliveryClient!; } /// Builds [CallOptions] with authentication metadata. /// /// Includes Bearer token in metadata if [authService] is configured and /// a valid token is available. Uses [AuthService.ensureValidToken] to /// proactively refresh tokens that are expiring soon. Future _buildCallOptions() async { final metadata = {}; if (authService != null) { final token = await authService!.ensureValidToken(); if (token != null) { metadata['authorization'] = 'Bearer $token'; } } return CallOptions( metadata: metadata, timeout: config.timeout, ); } /// Merges base [CallOptions] with additional options for a specific call. CallOptions _mergeOptions(CallOptions base, CallOptions? additional) { if (additional == null) return base; return CallOptions( metadata: { ...base.metadata, ...additional.metadata, }, timeout: additional.timeout ?? base.timeout, providers: [ ...base.metadataProviders, ...additional.metadataProviders, ], ); } /// Maps gRPC [GrpcError] to [ApiError] for consistent error handling. /// /// Maps common gRPC status codes to appropriate [ApiErrorType]: /// - UNAUTHENTICATED (16) -> HTTP 401 /// - PERMISSION_DENIED (7) -> HTTP 403 /// - NOT_FOUND (5) -> HTTP 404 /// - INVALID_ARGUMENT (3) -> Validation error /// - DEADLINE_EXCEEDED (4) -> Timeout /// - UNAVAILABLE (14) -> Network error /// - Other codes -> Unknown error ApiError _mapGrpcError(GrpcError error) { switch (error.code) { case StatusCode.unauthenticated: return ApiError.http( statusCode: 401, message: error.message ?? 'Authentication required', ); case StatusCode.permissionDenied: return ApiError.http( statusCode: 403, message: error.message ?? 'Permission denied', ); case StatusCode.notFound: return ApiError.http( statusCode: 404, message: error.message ?? 'Resource not found', ); case StatusCode.invalidArgument: return ApiError.validation( error.message ?? 'Invalid request', null, ); case StatusCode.deadlineExceeded: return ApiError.timeout(); case StatusCode.unavailable: return ApiError.network( error.message ?? 'Service unavailable', ); case StatusCode.internal: return ApiError.http( statusCode: 500, message: error.message ?? 'Internal server error', ); default: return ApiError.unknown( error.message ?? 'gRPC error: ${error.codeName}', exception: error, ); } } /// Executes a gRPC call with `Result` error handling and authentication. /// /// This is the core method that wraps gRPC calls with: /// - Authentication token injection /// - Automatic token refresh on UNAUTHENTICATED errors (single retry) /// - gRPC error to ApiError mapping /// - Timeout handling /// /// [grpcCall] is the actual gRPC method invocation. /// [isRetry] tracks whether this is a retry after token refresh. Future> _executeWithAuth( Future Function(CallOptions options) grpcCall, { bool isRetry = false, }) async { try { final options = await _buildCallOptions(); final result = await grpcCall(options); return Result.success(result); } on GrpcError catch (error) { // Handle UNAUTHENTICATED by attempting token refresh (once) if (error.code == StatusCode.unauthenticated && !isRetry && authService != null) { final refreshResult = await authService!.refreshAccessToken(); return refreshResult.when( success: (token) => _executeWithAuth(grpcCall, isRetry: true), onError: (refreshError) => Result.error(_mapGrpcError(error)), cancelled: () => Result.error(_mapGrpcError(error)), ); } return Result.error(_mapGrpcError(error)); } on TimeoutException { return Result.error(ApiError.timeout()); } catch (e, stackTrace) { return Result.error( ApiError.unknown( 'gRPC call failed: ${e.toString()}', exception: Exception(stackTrace.toString()), ), ); } } /// Executes a gRPC call that returns void (for commands). /// /// Similar to [_executeWithAuth] but for operations that don't return /// meaningful data (commands that return success/failure). Future> _executeCommandWithAuth( Future Function(CallOptions options) grpcCall, { bool isRetry = false, }) async { try { final options = await _buildCallOptions(); await grpcCall(options); return Result.success(null); } on GrpcError catch (error) { if (error.code == StatusCode.unauthenticated && !isRetry && authService != null) { final refreshResult = await authService!.refreshAccessToken(); return refreshResult.when( success: (token) => _executeCommandWithAuth(grpcCall, isRetry: true), onError: (refreshError) => Result.error(_mapGrpcError(error)), cancelled: () => Result.error(_mapGrpcError(error)), ); } return Result.error(_mapGrpcError(error)); } on TimeoutException { return Result.error(ApiError.timeout()); } catch (e, stackTrace) { return Result.error( ApiError.unknown( 'gRPC command failed: ${e.toString()}', exception: Exception(stackTrace.toString()), ), ); } } // ============================================================ // Query Methods // ============================================================ /// Gets all delivery routes. /// /// Returns a [Result] containing a list of [DeliveryRoute] objects. /// Maps the gRPC [DeliveryRoutesResponse] to domain models. /// /// Example: /// ```dart /// final result = await client.getDeliveryRoutes(); /// result.when( /// success: (routes) => displayRoutes(routes), /// onError: (error) => showError(error.message), /// ); /// ``` Future>> getDeliveryRoutes() async { final result = await _executeWithAuth( (options) => deliveryClient.getDeliveryRoutes(Empty(), options: options), ); return result.when( success: (response) { final routes = response.routes.map(_mapDeliveryRouteProto).toList(); return Result.success(routes); }, onError: (error) => Result.error(error), ); } /// Maps a [DeliveryRouteProto] to a [DeliveryRoute] domain model. DeliveryRoute _mapDeliveryRouteProto(DeliveryRouteProto proto) { return DeliveryRoute( id: proto.id, routeId: proto.routeId, name: proto.name, routeName: proto.routeName, deliveriesCount: proto.deliveriesCount, deliveredCount: proto.deliveredCount, completed: proto.completed, createdAt: proto.createdAt, ); } /// Shuts down the gRPC channel and releases resources. /// /// Should be called when the client is no longer needed to properly /// clean up network resources. Future shutdown() async { await _channel?.shutdown(); _channel = null; _deliveryClient = null; } /// Terminates the gRPC channel immediately. /// /// Unlike [shutdown], this does not wait for pending calls to complete. /// Use this for emergency cleanup or when the app is terminating. Future terminate() async { await _channel?.terminate(); _channel = null; _deliveryClient = null; } /// Returns true if the channel is currently active. bool get isConnected => _channel != null; }