package com.bigcrunch.ads.core import com.bigcrunch.ads.internal.BCLogger import com.bigcrunch.ads.internal.HttpClient import com.bigcrunch.ads.internal.KeyValueStore import com.bigcrunch.ads.models.AppConfig import com.bigcrunch.ads.models.PlacementConfig import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonEncodingException import com.squareup.moshi.Moshi import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock class ConfigParseException(message: String, cause: Throwable? = null) : Exception(message, cause) /** * Manages application configuration fetching, caching, and access * * ConfigManager is responsible for: * - Fetching config from BigCrunch backend * - Caching config in memory for fast access * - Persisting config to storage for offline/cold start * - Thread-safe config access * - Placement lookup by ID * * The config is fetched once at SDK initialization and cached for the app session. */ internal class ConfigManager( private val httpClient: HttpClient, private val storage: KeyValueStore, private val moshi: Moshi ) { private val mutex = Mutex() @Volatile private var cachedConfig: AppConfig? = null private val adapter = moshi.adapter(AppConfig::class.java) companion object { private const val CONFIG_STORAGE_KEY = "app_config" private const val PROD_BASE_URL = "https://ship.bigcrunch.com" private const val STAGING_BASE_URL = "https://dev-ship.bigcrunch.com" /** * Generate mock configuration for testing * * Uses Google's official sample ad units: * - Banner: ca-app-pub-3940256099942544/6300978111 (adaptive banner) * - MREC: ca-app-pub-3940256099942544/6300978111 (adaptive banner - same) * - Interstitial: ca-app-pub-3940256099942544/1033173712 */ private fun getMockConfig(propertyId: String): AppConfig { return AppConfig( propertyId = propertyId, appName = "Mock Test App", gamNetworkCode = "", // Empty for AdMob sample ads s2s = com.bigcrunch.ads.models.S2SConfig( serverUrl = "https://s2s.bigcrunch.com/auction", timeoutMs = 3000 ), bidders = null, // No bidders in mock/test mode amazonAps = null, useTestAds = true, refresh = com.bigcrunch.ads.models.RefreshConfig(enabled = true, intervalMs = 30000, maxRefreshes = 20), placements = listOf( PlacementConfig( id = "00000000-0000-0000-0000-000000000001", placementId = "test_banner_320x50", format = "banner", gamAdUnit = "ca-app-pub-3940256099942544/6300978111", sizes = listOf(com.bigcrunch.ads.models.AdSize(width = 320, height = 50)) ), PlacementConfig( id = "00000000-0000-0000-0000-000000000002", placementId = "test_mrec", format = "banner", gamAdUnit = "ca-app-pub-3940256099942544/6300978111", sizes = listOf(com.bigcrunch.ads.models.AdSize(width = 300, height = 250)), refresh = com.bigcrunch.ads.models.RefreshConfig(enabled = true, intervalMs = 15000, maxRefreshes = 40) ), PlacementConfig( id = "00000000-0000-0000-0000-000000000003", placementId = "test_adaptive_banner", format = "banner", gamAdUnit = "ca-app-pub-3940256099942544/6300978111", sizes = listOf(com.bigcrunch.ads.models.AdSize.adaptive()) ), PlacementConfig( id = "00000000-0000-0000-0000-000000000004", placementId = "test_interstitial", format = "interstitial", gamAdUnit = "ca-app-pub-3940256099942544/1033173712", sizes = null ), PlacementConfig( id = "00000000-0000-0000-0000-000000000005", placementId = "test_rewarded", format = "rewarded", gamAdUnit = "ca-app-pub-3940256099942544/5224354917", sizes = null ) ) ) } } /** * Load configuration from BigCrunch backend or mock data * * This method: * 1. If useMockConfig is true, returns hardcoded mock config immediately * 2. Attempts to load cached config from storage first (for offline/cold start) * 3. Fetches fresh config from network * 4. If network succeeds, updates cache and storage * 5. If network fails but cache exists, returns cached version * 6. If network fails and no cache, returns error * * @param propertyId BigCrunch property ID * @param isProd True for production, false for staging * @param useMockConfig If true, returns mock config for testing * @return Result containing AppConfig or error */ suspend fun loadConfig( propertyId: String, isProd: Boolean, useMockConfig: Boolean = false ): Result = mutex.withLock { BCLogger.d("ConfigManager", "Loading config for property: $propertyId (mock: $useMockConfig)") // If mock mode enabled, return mock config immediately if (useMockConfig) { val mockConfig = getMockConfig(propertyId) cachedConfig = mockConfig BCLogger.i("ConfigManager", "Using mock config (${mockConfig.placements.size} placements)") return Result.success(mockConfig) } // 1. Try to load from storage first (for offline/cold start) val storedConfig = loadFromStorage() if (storedConfig != null) { cachedConfig = storedConfig BCLogger.d("ConfigManager", "Loaded config from storage") } // 2. Fetch fresh config from network val baseUrl = if (isProd) PROD_BASE_URL else STAGING_BASE_URL val url = "$baseUrl/config-app/$propertyId.json" val result = httpClient.get( url = url, headers = emptyMap() // No authentication required for public config files ) return when { result.isSuccess -> { // Network request succeeded val json = result.getOrNull()!! val config = try { adapter.fromJson(json) } catch (e: Exception) { val message = describeJsonError(e) BCLogger.e("ConfigManager", "Failed to parse config JSON from $url: $message", e) return Result.failure(ConfigParseException(message, e)) } if (config != null) { cachedConfig = config saveToStorage(json) val placementIds = config.placements.map { it.placementId } BCLogger.i("ConfigManager", "Config loaded successfully from network (${config.placements.size} placements): $placementIds") BCLogger.v("ConfigManager", "Config JSON: $json") Result.success(config) } else { BCLogger.e("ConfigManager", "Invalid config JSON") Result.failure(ConfigParseException("Config JSON parsed to null")) } } storedConfig != null -> { // Network failed but we have cached version val cachedIds = storedConfig.placements.map { it.placementId } BCLogger.w("ConfigManager", "Config fetch failed for $url: ${result.exceptionOrNull()?.message}. Using cached config (cached placements: $cachedIds)") Result.success(storedConfig) } else -> { // Network failed and no cache available BCLogger.e("ConfigManager", "Config load failed for $url and no cache available") Result.failure(result.exceptionOrNull() ?: Exception("Config load failed")) } } } /** * Get the cached app configuration * * @return The cached AppConfig, or null if not loaded */ fun getCachedConfig(): AppConfig? = cachedConfig /** * Get placement configuration by ID * * This is a synchronous operation that looks up the placement in the cached config. * Must be called after loadConfig() has succeeded at least once. * * @param placementId The placement ID to look up * @return PlacementConfig if found, null otherwise */ fun getPlacement(placementId: String): PlacementConfig? { val placement = cachedConfig?.placements?.firstOrNull { it.placementId == placementId } if (placement == null) { BCLogger.w("ConfigManager", "Placement not found: $placementId") } else { BCLogger.v("ConfigManager", "Found placement: $placementId (${placement.format})") } return placement } /** * Get all placements from cached config * * @return List of all placements, or empty list if config not loaded */ fun getAllPlacements(): List { return cachedConfig?.placements ?: emptyList() } /** * Get the GAM network code from cached config * * @return The GAM network code, or null if config not loaded */ fun getGamNetworkCode(): String? { return cachedConfig?.gamNetworkCode } /** * Get the S2S config from cached config * * @return The S2S config, or null if config not loaded */ fun getS2SConfig(): com.bigcrunch.ads.models.S2SConfig? { return cachedConfig?.s2s } /** * Get the effective refresh config for a placement * * Resolution order: * 1. Placement-level refresh config (if present, even if disabled) * 2. Global refresh config from AppConfig * 3. null (no refresh) * * @param placementId The placement ID to look up * @return RefreshConfig if refresh is configured, null otherwise */ fun getEffectiveRefreshConfig(placementId: String): com.bigcrunch.ads.models.RefreshConfig? { val placement = getPlacement(placementId) ?: return null // Placement-level override takes priority if (placement.refresh != null) { return placement.refresh } // Fall back to global config return cachedConfig?.refresh } /** Override for useTestAds, set via initialize() options. Takes precedence over config value. */ var useTestAdsOverride: Boolean? = null /** * Check if test ads mode is enabled in config * * @return true if test ads should be used, false otherwise */ fun shouldUseTestAds(): Boolean { useTestAdsOverride?.let { return it } return cachedConfig?.useTestAds ?: false } /** * Clear cached config (for testing) */ internal fun clearCache() { cachedConfig = null storage.clear() BCLogger.d("ConfigManager", "Cache cleared") } /** * Load config from persistent storage */ private fun loadFromStorage(): AppConfig? { return try { val json = storage.getString(CONFIG_STORAGE_KEY) ?: return null adapter.fromJson(json) } catch (e: Exception) { BCLogger.e("ConfigManager", "Failed to load config from storage: ${describeJsonError(e)}", e) null } } /** * Produce a human-readable description from a Moshi parse error */ private fun describeJsonError(e: Exception): String { return when (e) { is JsonDataException -> e.message ?: "Invalid JSON data" is JsonEncodingException -> e.message ?: "Malformed JSON" else -> e.message ?: e.toString() } } /** * Save config to persistent storage */ private fun saveToStorage(json: String) { try { storage.putString(CONFIG_STORAGE_KEY, json) BCLogger.v("ConfigManager", "Config saved to storage") } catch (e: Exception) { BCLogger.e("ConfigManager", "Failed to save config to storage", e) } } }