import Foundation

/**
 * 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.
 */
enum ConfigError: LocalizedError {
    case parseError(String)

    var errorDescription: String? {
        switch self {
        case .parseError(let message):
            return message
        }
    }
}

internal class ConfigManager {

    private let httpClient: HTTPClient
    private let storage: KeyValueStore
    private let lock = NSLock()

    private var cachedConfig: AppConfig?

    private static let configStorageKey = "app_config"
    private static let prodBaseURL = "https://ship.bigcrunch.com"
    private static let stagingBaseURL = "https://dev-ship.bigcrunch.com"

    init(httpClient: HTTPClient, storage: KeyValueStore) {
        self.httpClient = httpClient
        self.storage = storage
    }

    /**
     * Generate mock configuration for testing
     *
     * Uses Google's official sample ad units:
     * - Banner: ca-app-pub-3940256099942544/2435281174 (320x50)
     * - MREC: ca-app-pub-3940256099942544/6300978111 (300x250 - same as banner sample)
     * - Interstitial: ca-app-pub-3940256099942544/4411468910
     *
     * For GAM, we need to use a GAM-style ad unit path format.
     * Using empty network code with sample ad unit IDs for testing.
     */
    private static func getMockConfig(propertyId: String) -> AppConfig {
        return AppConfig(
            propertyId: propertyId,
            appName: "Mock Test App",
            gamNetworkCode: "",  // Empty for AdMob sample ads
            s2s: S2SConfig(
                serverUrl: "https://s2s.bigcrunch.com/auction",
                timeoutMs: 3000
            ),
            bidders: nil,  // No bidders in mock/test mode
            amazonAps: nil,
            useTestAds: true,  // Use Google's test ad units in mock mode
            refresh: RefreshConfig(enabled: true, intervalMs: 30000, maxRefreshes: 20),
            placements: [
                PlacementConfig(
                    id: "00000000-0000-0000-0000-000000000001",
                    placementId: "test_banner_320x50",
                    format: "banner",
                    gamAdUnit: "ca-app-pub-3940256099942544/2435281174",
                    sizes: [AdSize(width: 320, height: 50)]
                ),
                PlacementConfig(
                    id: "00000000-0000-0000-0000-000000000002",
                    placementId: "test_mrec",
                    format: "banner",
                    gamAdUnit: "ca-app-pub-3940256099942544/2435281174",
                    sizes: [AdSize(width: 300, height: 250)],
                    refresh: 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/2435281174",
                    sizes: [AdSize.adaptive()]
                ),
                PlacementConfig(
                    id: "00000000-0000-0000-0000-000000000004",
                    placementId: "test_interstitial",
                    format: "interstitial",
                    gamAdUnit: "ca-app-pub-3940256099942544/4411468910",
                    sizes: nil
                ),
                PlacementConfig(
                    id: "00000000-0000-0000-0000-000000000005",
                    placementId: "test_rewarded",
                    format: "rewarded",
                    gamAdUnit: "ca-app-pub-3940256099942544/1712485313",
                    sizes: nil
                )
            ]
        )
    }

    /**
     * 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
     *
     * - Parameters:
     *   - propertyId: BigCrunch property ID
     *   - isProd: True for production, false for staging
     *   - useMockConfig: If true, returns mock config for testing
     * - Returns: Result containing AppConfig or error
     */
    func loadConfig(
        propertyId: String,
        isProd: Bool,
        useMockConfig: Bool = false
    ) async -> Result<AppConfig, Error> {
        BCLogger.debug("Loading config for property: \(propertyId) (mock: \(useMockConfig))")

        // If mock mode enabled, return mock config immediately
        if useMockConfig {
            let mockConfig = Self.getMockConfig(propertyId: propertyId)
            setCachedConfig(mockConfig)
            BCLogger.info("Using mock config (\(mockConfig.placements.count) placements)")
            return .success(mockConfig)
        }

        // 1. Try to load from storage first (for offline/cold start)
        if let storedConfig = loadFromStorage() {
            setCachedConfig(storedConfig)
            BCLogger.debug("Loaded config from storage")
        }

        // 2. Fetch fresh config from network
        let baseURL = isProd ? Self.prodBaseURL : Self.stagingBaseURL
        let url = "\(baseURL)/config-app/\(propertyId).json"

        let result = await httpClient.get(
            url: url,
            headers: [:]
        )

        switch result {
        case .success(let json):
            // Network request succeeded
            do {
                let data = json.data(using: .utf8)!
                let config = try JSONDecoder().decode(AppConfig.self, from: data)
                setCachedConfig(config)
                saveToStorage(json)
                let placementIds = config.placements.map { $0.placementId }
                BCLogger.info("Config loaded successfully from network (\(config.placements.count) placements): \(placementIds)")
                BCLogger.verbose("Config JSON: \(json)")
                return .success(config)
            } catch {
                let message = Self.describeDecodingError(error)
                BCLogger.error("Failed to parse config JSON from \(url): \(message)")
                return .failure(ConfigError.parseError(message))
            }

        case .failure(let error):
            // Network failed, check for cached version
            if let cached = getCachedConfig() {
                let cachedIds = cached.placements.map { $0.placementId }
                BCLogger.warning("Config fetch failed for \(url): \(error). Using cached config (cached placements: \(cachedIds))")
                return .success(cached)
            } else {
                BCLogger.error("Config load failed for \(url) and no cache available")
                return .failure(error)
            }
        }
    }

    /**
     * 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.
     *
     * - Parameter placementId: The placement ID to look up
     * - Returns: PlacementConfig if found, nil otherwise
     */
    func getPlacement(_ placementId: String) -> PlacementConfig? {
        let placement = getCachedConfig()?.placements.first {
            $0.placementId == placementId
        }

        if placement == nil {
            BCLogger.warning("Placement not found: \(placementId)")
        } else {
            BCLogger.verbose("Found placement: \(placementId) (\(placement!.format))")
        }

        return placement
    }

    /**
     * Get all placements from cached config
     *
     * - Returns: List of all placements, or empty list if config not loaded
     */
    func getAllPlacements() -> [PlacementConfig] {
        return getCachedConfig()?.placements ?? []
    }

    /**
     * Get the GAM network code from cached config
     *
     * - Returns: The GAM network code, or nil if config not loaded
     */
    func getGamNetworkCode() -> String? {
        return getCachedConfig()?.gamNetworkCode
    }

    /**
     * Get the S2S config from cached config
     *
     * - Returns: The S2S config, or nil if config not loaded
     */
    func getS2SConfig() -> S2SConfig? {
        return getCachedConfig()?.s2s
    }

    /**
     * Get the cached app configuration
     *
     * - Returns: The cached AppConfig, or nil if not yet loaded
     */
    func getCachedConfig() -> AppConfig? {
        lock.lock()
        defer { lock.unlock() }
        return cachedConfig
    }

    /**
     * Replace the cached config under the lock
     *
     * Keep the critical section to just this assignment — holding the lock across
     * network awaits would block readers and unlock on a different thread.
     */
    private func setCachedConfig(_ config: AppConfig?) {
        lock.lock()
        defer { lock.unlock() }
        cachedConfig = config
    }

    /// Override for useTestAds, set via initialize() options. Takes precedence over config value.
    var useTestAdsOverride: Bool?

    /**
     * Check if test ads should be used
     *
     * Returns true if the override is set, or if the cached config has useTestAds enabled.
     * When true, GoogleAdsAdapter should substitute production ad units with Google's test ad units.
     *
     * - Returns: True if test ads should be used, false otherwise
     */
    func shouldUseTestAds() -> Bool {
        if let override = useTestAdsOverride {
            return override
        }
        lock.lock()
        defer { lock.unlock() }
        return cachedConfig?.useTestAds ?? false
    }

    /**
     * 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. nil (no refresh)
     *
     * - Parameter placementId: The placement ID to look up
     * - Returns: RefreshConfig if refresh is configured, nil otherwise
     */
    func getEffectiveRefreshConfig(placementId: String) -> RefreshConfig? {
        // Single snapshot so placement and global fallback come from the same config
        guard let config = getCachedConfig() else { return nil }
        guard let placement = config.placements.first(where: { $0.placementId == placementId }) else {
            BCLogger.warning("Placement not found: \(placementId)")
            return nil
        }
        // Placement-level override takes priority
        if let placementRefresh = placement.refresh {
            return placementRefresh
        }
        // Fall back to global config
        return config.refresh
    }

    /**
     * Clear cached config (for testing)
     */
    func clearCache() {
        lock.lock()
        defer { lock.unlock() }

        cachedConfig = nil
        storage.clear()
        BCLogger.debug("Cache cleared")
    }

    /**
     * Load config from persistent storage
     */
    private func loadFromStorage() -> AppConfig? {
        guard let json = storage.getString(key: Self.configStorageKey, default: nil),
              let data = json.data(using: .utf8) else {
            return nil
        }

        do {
            return try JSONDecoder().decode(AppConfig.self, from: data)
        } catch {
            BCLogger.error("Failed to load config from storage: \(Self.describeDecodingError(error))")
            return nil
        }
    }

    /**
     * Produce a human-readable description from a DecodingError
     */
    private static func describeDecodingError(_ error: Error) -> String {
        guard let decodingError = error as? DecodingError else {
            return error.localizedDescription
        }

        let path: String
        let detail: String

        switch decodingError {
        case .keyNotFound(let key, let context):
            path = Self.formatCodingPath(context.codingPath)
            detail = "missing required field '\(key.stringValue)'"
        case .typeMismatch(let type, let context):
            path = Self.formatCodingPath(context.codingPath)
            detail = "expected \(type)"
        case .valueNotFound(let type, let context):
            path = Self.formatCodingPath(context.codingPath)
            detail = "null value for \(type)"
        case .dataCorrupted(let context):
            path = Self.formatCodingPath(context.codingPath)
            detail = context.debugDescription
        @unknown default:
            return error.localizedDescription
        }

        return path.isEmpty ? detail : "\(path): \(detail)"
    }

    private static func formatCodingPath(_ codingPath: [CodingKey]) -> String {
        return codingPath.map { key in
            if let index = key.intValue {
                return "[\(index)]"
            }
            return key.stringValue
        }.joined(separator: ".")
    }

    /**
     * Save config to persistent storage
     */
    private func saveToStorage(_ json: String) {
        do {
            storage.putString(key: Self.configStorageKey, value: json)
            BCLogger.verbose("Config saved to storage")
        } catch {
            BCLogger.error("Failed to save config to storage: \(error)")
        }
    }
}
