discoverTenant function

Future<DiscoveryRecord> discoverTenant({
  1. String? controlPlaneUrl,
  2. String? firebaseToken,
  3. String? tenantHint,
  4. bool forceRefresh = false,
  5. Duration timeout = const Duration(seconds: 10),
  6. File? cacheFile,
})

Resolves discovery info via Control Plane.

If forceRefresh is false, a non-expired cached result is used.

Implementation

Future<DiscoveryRecord> discoverTenant({
  String? controlPlaneUrl,
  String? firebaseToken,
  String? tenantHint,
  bool forceRefresh = false,
  Duration timeout = const Duration(seconds: 10),
  File? cacheFile,
}) async {
  final cpUrl = (controlPlaneUrl == null || controlPlaneUrl.trim().isEmpty)
      ? getControlPlaneUrl()
      : controlPlaneUrl.trim();

  KumihoCredentials? credentials;
  String? token;

  if (firebaseToken != null && firebaseToken.trim().isNotEmpty) {
    token = firebaseToken.trim();
  } else {
    // Prefer explicit env var for discovery.
    final envToken = Platform.environment[AuthEnvVars.firebaseToken];
    if (envToken != null && envToken.trim().isNotEmpty) {
      token = envToken.trim();
    } else {
      // Fall back to cached credentials, optionally auto-refreshing.
      credentials = loadCredentials();
      final refreshed = await autoRefreshCredentials(credentials);
      credentials = refreshed ?? credentials;
      token = credentials?.idToken;
    }
  }

  if (token == null || token.trim().isEmpty) {
    throw ArgumentError(
      'A Firebase ID token is required for discovery. '
      'Set KUMIHO_FIREBASE_ID_TOKEN or login via kumiho-cli '
      '(~/.kumiho/kumiho_authentication.json).',
    );
  }

  final file = cacheFile ?? getDefaultDiscoveryCacheFile();
  final key = (tenantHint == null || tenantHint.trim().isEmpty)
      ? _defaultCacheKey
      : tenantHint.trim();

  if (!forceRefresh) {
    final cached = _loadFromCache(file, key);
    if (cached != null) return cached;
  }

  final url = _buildDiscoveryUrl(cpUrl);
  final body = <String, Object>{
    if (tenantHint != null && tenantHint.trim().isNotEmpty)
      'tenant_hint': tenantHint.trim(),
  };

  Future<http.Response> postWithToken(String bearer) {
    return http
        .post(
          url,
          headers: {
            'Authorization': 'Bearer $bearer',
            'Content-Type': 'application/json',
          },
          body: jsonEncode(body),
        )
        .timeout(timeout);
  }

  var response = await postWithToken(token);

  // If the cached token is expired/invalid, retry once after forcing refresh.
  if (response.statusCode == 401 &&
      firebaseToken == null &&
      (Platform.environment[AuthEnvVars.firebaseToken]?.trim().isEmpty ?? true) &&
      credentials != null &&
      credentials.refreshToken.isNotEmpty) {
    final bodyText = response.body;
    if (bodyText.contains('invalid_id_token')) {
      final refreshed = await autoRefreshCredentials(credentials, forceRefresh: true);
      if (refreshed != null && refreshed.idToken.isNotEmpty && refreshed.idToken != token) {
        token = refreshed.idToken;
        response = await postWithToken(token);
      }
    }
  }

  if (response.statusCode >= 400) {
    final snippet = response.body.length > 200
        ? response.body.substring(0, 200)
        : response.body;
    throw HttpException(
      'Discovery endpoint returned ${response.statusCode}: $snippet',
      uri: url,
    );
  }

  final decoded = jsonDecode(response.body);
  final record = _parseDiscoveryRecord(decoded);

  // Cache the raw JSON payload so we can re-parse it later.
  if (decoded is Map<String, dynamic>) {
    _storeToCache(file, key, decoded);
  }

  return record;
}