import Foundation
import UIKit
import GoogleMobileAds

/**
 * Callback protocol for banner ad events
 */
public protocol BannerCallback: AnyObject {
    func onAdLoaded()
    func onAdFailedToLoad(error: String)
    func onAdClicked()
    func onAdImpression()
}

/**
 * Callback protocol for interstitial ad events
 */
public protocol InterstitialCallback: AnyObject {
    func onAdLoaded()
    func onAdFailedToLoad(error: String)
    func onAdShowed()
    func onAdDismissed()
    func onAdClicked()
}

/**
 * AdOrchestrator - Central orchestration for ad loading lifecycle
 *
 * Coordinates the flow between ConfigManager, BidRequestClient, GoogleAdsAdapter,
 * and AnalyticsClient to load and display ads.
 *
 * Flow:
 * 1. Get PlacementConfig from ConfigManager
 * 2. Track ad request via AnalyticsClient
 * 3. Fetch S2S demand via BidRequestClient
 * 4. Load Google ad via GoogleAdsAdapter (with targeting from S2S)
 * 5. Wire up all callbacks for analytics
 */
internal class AdOrchestrator {

    private static let TAG = "AdOrchestrator"

    private let configManager: ConfigManager
    private let analyticsClient: AnalyticsClient
    private let bidRequestClient: BidRequestClient
    private let googleAdsAdapter: GoogleAdsAdapter

    // Cache for preloaded interstitial ads, paired with the loadId of the
    // S2S auction data stored for that specific load
    private var interstitialCache: [String: (ad: GoogleMobileAds.InterstitialAd, loadId: String)] = [:]
    private let interstitialLock = NSLock()

    // Track active banner views for cleanup
    private var activeBanners: [String: GoogleMobileAds.BannerView] = [:]
    private let bannerLock = NSLock()

    // Store callback wrappers to prevent deallocation
    private var bannerCallbackWrappers: [String: BannerCallbackWrapper] = [:]
    private var interstitialCallbackWrappers: [String: InterstitialCallbackWrapper] = [:]

    init(
        configManager: ConfigManager,
        analyticsClient: AnalyticsClient,
        bidRequestClient: BidRequestClient,
        googleAdsAdapter: GoogleAdsAdapter
    ) {
        self.configManager = configManager
        self.analyticsClient = analyticsClient
        self.bidRequestClient = bidRequestClient
        self.googleAdsAdapter = googleAdsAdapter
    }

    /**
     * Load a banner ad
     *
     * - Parameters:
     *   - placementId: The placement ID from the BigCrunch dashboard
     *   - rootViewController: The view controller for presenting ad click actions
     *   - callback: Callback for ad events
     *   - adSizeOverride: Optional size override (takes precedence over backend config)
     *   - bannerViewSetter: Closure to set the banner view on the container.
     *     Returns false if the container was destroyed while the load was in flight,
     *     in which case the orphaned ad view is destroyed instead of attached.
     * - Returns: The GoogleMobileAds.BannerView if loading was initiated, nil if placement not found
     */
    func loadBannerAd(
        placementId: String,
        rootViewController: UIViewController,
        callback: BannerCallback,
        refreshCount: Int = 0,
        adSizeOverride: AdSize? = nil,
        bannerViewSetter: ((GoogleMobileAds.BannerView) -> Bool)? = nil
    ) -> GoogleMobileAds.BannerView? {
        BCLogger.debug("\(AdOrchestrator.TAG): Loading banner ad: \(placementId) (refreshCount: \(refreshCount))")

        // 1. Get placement config
        guard let placement = configManager.getPlacement(placementId) else {
            BCLogger.error("\(AdOrchestrator.TAG): Placement not found: \(placementId)")
            analyticsClient.trackUnfilledImpression(placementId: placementId, slotId: placementId, format: "banner")
            callback.onAdFailedToLoad(error: "Placement not found: \(placementId)")
            return nil
        }

        guard placement.format == "banner" else {
            BCLogger.error("\(AdOrchestrator.TAG): Invalid format for banner: \(placement.format)")
            analyticsClient.trackUnfilledImpression(placementId: placementId, slotId: placement.id, format: placement.format, gamAdUnit: placement.gamAdUnit)
            callback.onAdFailedToLoad(error: "Invalid placement format: \(placement.format)")
            return nil
        }

        guard placement.enabled else {
            BCLogger.debug("\(AdOrchestrator.TAG): Placement disabled: \(placementId)")
            analyticsClient.trackUnfilledImpression(placementId: placementId, slotId: placement.id, format: placement.format, gamAdUnit: placement.gamAdUnit)
            callback.onAdFailedToLoad(error: "Placement is disabled: \(placementId)")
            return nil
        }

        // 2. Track ad request
        analyticsClient.trackAdRequest(placementId: placementId, format: placement.format)

        // 3. Create callback wrapper and store it
        let callbackWrapper = BannerCallbackWrapper(
            placementId: placementId,
            callback: callback
        )
        bannerLock.lock()
        bannerCallbackWrappers[placementId] = callbackWrapper
        bannerLock.unlock()

        // 4. Start async ad loading
        Task {
            await loadBannerAsync(
                placement: placement,
                rootViewController: rootViewController,
                callbackWrapper: callbackWrapper,
                refreshCount: refreshCount,
                adSizeOverride: adSizeOverride,
                bannerViewSetter: bannerViewSetter
            )
        }

        // Return nil initially - the banner view will be created by GoogleAdsAdapter
        return nil
    }

    private func loadBannerAsync(
        placement: PlacementConfig,
        rootViewController: UIViewController,
        callbackWrapper: BannerCallbackWrapper,
        refreshCount: Int,
        adSizeOverride: AdSize?,
        bannerViewSetter: ((GoogleMobileAds.BannerView) -> Bool)?
    ) async {
        // Get GAM network code from config
        guard let gamNetworkCode = configManager.getGamNetworkCode() else {
            BCLogger.error("\(AdOrchestrator.TAG): GAM network code not available")
            analyticsClient.trackUnfilledImpression(placementId: placement.placementId, slotId: placement.id, format: placement.format, gamAdUnit: placement.gamAdUnit)
            callbackWrapper.callback?.onAdFailedToLoad(error: "Config not loaded")
            return
        }

        // Note: ad size resolution happens in GoogleAdsAdapter (on the main actor —
        // GMA's adaptive-size APIs touch UIKit) and in BidRequestClient for S2S imps.
        // Resolving here would run GMA UIKit calls on a background thread.

        // Create ad request
        let adRequest = GoogleMobileAds.Request()

        // 3. Fetch S2S demand (even if it fails, continue with Google)
        let bidResponse = await bidRequestClient.fetchDemand(placement: placement)
        if let targeting = bidResponse?.targeting, !targeting.isEmpty {
            var customTargeting = adRequest.customTargeting ?? [:]
            for (key, value) in targeting {
                customTargeting[key] = value
            }
            adRequest.customTargeting = customTargeting
            BCLogger.debug("\(AdOrchestrator.TAG): S2S demand fetched for: \(placement.placementId) (\(targeting.count) keys)")
        } else {
            BCLogger.warning("\(AdOrchestrator.TAG): S2S demand fetch returned no targeting, continuing with Google only")
        }

        // Store auction data for analytics (or default to GAM if no S2S),
        // keyed by a per-load ID so concurrent loads/refreshes don't clobber each other
        let loadId = UUID().uuidString
        let auctionData = bidResponse?.auctionData ?? AuctionData(
            auctionId: nil, bidder: "google_ad_exchange", bidPriceCpm: nil, creativeId: nil,
            gamPriceBucket: nil, floorPrice: placement.floorPrice, secondHighestBid: nil,
            demandChannel: "Google Ad Exchange"
        )
        analyticsClient.setAuctionData(loadId: loadId, auctionData: auctionData)

        // 4. Load Google ad with the (possibly enriched) ad request
        let delegateWrapper = BannerDelegateAdapterWrapper(
            placementId: placement.placementId,
            callbackWrapper: callbackWrapper,
            analyticsClient: analyticsClient,
            placementConfig: placement
        )

        // Check if we should use test ad units
        let useTestAds = configManager.shouldUseTestAds()

        let bannerView = await googleAdsAdapter.loadBannerAd(
            placementConfig: placement,
            gamNetworkCode: gamNetworkCode,
            request: adRequest,
            rootViewController: rootViewController,
            delegate: delegateWrapper,
            useTestAds: useTestAds,
            adSizeOverride: adSizeOverride,
            refreshCount: refreshCount,
            loadId: loadId
        )

        // Set the banner view on the container (on main thread). The setter returns
        // false if the container was destroyed while the load was suspended at the
        // S2S fetch — in that case destroy the orphaned GMA view instead of leaking
        // a detached, still-refreshing banner.
        let accepted = await MainActor.run {
            bannerViewSetter?(bannerView) ?? true
        }
        guard accepted else {
            BCLogger.debug("\(AdOrchestrator.TAG): Banner container destroyed during load, discarding ad: \(placement.placementId)")
            analyticsClient.discardAuctionData(loadId: loadId)
            await MainActor.run {
                googleAdsAdapter.destroyBannerAd(bannerView)
            }
            return
        }

        // Track the active banner for cleanup
        bannerLock.lock()
        activeBanners[placement.placementId] = bannerView
        bannerLock.unlock()

        // Store the delegate wrapper to prevent deallocation
        callbackWrapper.delegateWrapper = delegateWrapper
    }

    /**
     * Destroy a banner ad
     *
     * - Parameter placementId: The placement ID of the banner to destroy
     */
    func destroyBannerAd(placementId: String) {
        BCLogger.debug("\(AdOrchestrator.TAG): Destroying banner ad: \(placementId)")

        bannerLock.lock()
        if let bannerView = activeBanners.removeValue(forKey: placementId) {
            googleAdsAdapter.destroyBannerAd(bannerView)
        }
        // Clean up callback wrapper (same lock as registration in loadBannerAd)
        bannerCallbackWrappers.removeValue(forKey: placementId)
        bannerLock.unlock()
    }

    /**
     * Preload an interstitial ad
     *
     * - Parameters:
     *   - placementId: The placement ID from the BigCrunch dashboard
     *   - callback: Callback for ad events
     */
    func preloadInterstitialAd(
        placementId: String,
        callback: InterstitialCallback
    ) {
        BCLogger.debug("\(AdOrchestrator.TAG): Preloading interstitial ad: \(placementId)")

        // 1. Get placement config
        guard let placement = configManager.getPlacement(placementId) else {
            BCLogger.error("\(AdOrchestrator.TAG): Placement not found: \(placementId)")
            analyticsClient.trackUnfilledImpression(placementId: placementId, slotId: placementId, format: "interstitial")
            callback.onAdFailedToLoad(error: "Placement not found: \(placementId)")
            return
        }

        guard placement.format == "interstitial" else {
            BCLogger.error("\(AdOrchestrator.TAG): Invalid format for interstitial: \(placement.format)")
            analyticsClient.trackUnfilledImpression(placementId: placementId, slotId: placement.id, format: placement.format, gamAdUnit: placement.gamAdUnit)
            callback.onAdFailedToLoad(error: "Invalid placement format: \(placement.format)")
            return
        }

        guard placement.enabled else {
            BCLogger.debug("\(AdOrchestrator.TAG): Placement disabled: \(placementId)")
            analyticsClient.trackUnfilledImpression(placementId: placementId, slotId: placement.id, format: placement.format, gamAdUnit: placement.gamAdUnit)
            callback.onAdFailedToLoad(error: "Placement is disabled: \(placementId)")
            return
        }

        // 2. Track ad request
        analyticsClient.trackAdRequest(placementId: placementId, format: placement.format)

        // 3. Start async ad loading
        Task {
            await preloadInterstitialAsync(placement: placement, callback: callback)
        }
    }

    private func preloadInterstitialAsync(
        placement: PlacementConfig,
        callback: InterstitialCallback
    ) async {
        interstitialLock.lock()
        // Check if already cached
        if interstitialCache[placement.placementId] != nil {
            interstitialLock.unlock()
            BCLogger.debug("\(AdOrchestrator.TAG): Interstitial already cached: \(placement.placementId)")
            callback.onAdLoaded()
            return
        }
        interstitialLock.unlock()

        // Get GAM network code from config
        guard let gamNetworkCode = configManager.getGamNetworkCode() else {
            BCLogger.error("\(AdOrchestrator.TAG): GAM network code not available")
            analyticsClient.trackUnfilledImpression(placementId: placement.placementId, slotId: placement.id, format: placement.format, gamAdUnit: placement.gamAdUnit)
            callback.onAdFailedToLoad(error: "Config not loaded")
            return
        }

        // Create ad request
        let adRequest = GoogleMobileAds.Request()

        // 3. Fetch S2S demand
        let bidResponse = await bidRequestClient.fetchDemand(placement: placement)
        if let targeting = bidResponse?.targeting, !targeting.isEmpty {
            var customTargeting = adRequest.customTargeting ?? [:]
            for (key, value) in targeting {
                customTargeting[key] = value
            }
            adRequest.customTargeting = customTargeting
            BCLogger.debug("\(AdOrchestrator.TAG): S2S demand fetched for: \(placement.placementId) (\(targeting.count) keys)")
        } else {
            BCLogger.warning("\(AdOrchestrator.TAG): S2S demand fetch returned no targeting, continuing with Google only")
        }

        // Store auction data for analytics (or default to GAM if no S2S),
        // keyed by a per-load ID so concurrent loads don't clobber each other
        let loadId = UUID().uuidString
        let auctionData = bidResponse?.auctionData ?? AuctionData(
            auctionId: nil, bidder: "google_ad_exchange", bidPriceCpm: nil, creativeId: nil,
            gamPriceBucket: nil, floorPrice: placement.floorPrice, secondHighestBid: nil,
            demandChannel: "Google Ad Exchange"
        )
        analyticsClient.setAuctionData(loadId: loadId, auctionData: auctionData)

        // Check if we should use test ad units
        let useTestAds = configManager.shouldUseTestAds()

        // 4. Load Google interstitial ad
        let result = await googleAdsAdapter.loadInterstitialAd(
            placementConfig: placement,
            gamNetworkCode: gamNetworkCode,
            request: adRequest,
            useTestAds: useTestAds
        )

        switch result {
        case .success(let interstitialAd):
            interstitialLock.lock()
            interstitialCache[placement.placementId] = (ad: interstitialAd, loadId: loadId)
            interstitialLock.unlock()
            BCLogger.debug("\(AdOrchestrator.TAG): Interstitial ad preloaded: \(placement.placementId)")
            callback.onAdLoaded()

        case .failure(let error):
            BCLogger.warning("\(AdOrchestrator.TAG): Interstitial ad failed to load: \(placement.placementId) - \(error.localizedDescription)")
            analyticsClient.discardAuctionData(loadId: loadId)
            analyticsClient.trackUnfilledImpression(
                placementId: placement.placementId,
                slotId: placement.id,
                format: placement.format,
                gamAdUnit: placement.gamAdUnit
            )
            callback.onAdFailedToLoad(error: error.localizedDescription)
        }
    }

    /**
     * Show an interstitial ad
     *
     * - Parameters:
     *   - viewController: The view controller to present the interstitial from
     *   - placementId: The placement ID from the BigCrunch dashboard
     *   - callback: Callback for ad events
     * - Returns: true if a preloaded ad was available and presentation was initiated;
     *   presentation failures are delivered asynchronously via callback.onAdFailedToLoad
     */
    func showInterstitialAd(
        from viewController: UIViewController,
        placementId: String,
        callback: InterstitialCallback
    ) -> Bool {
        BCLogger.debug("\(AdOrchestrator.TAG): Showing interstitial ad: \(placementId)")

        // Get placement config
        guard let placement = configManager.getPlacement(placementId) else {
            BCLogger.error("\(AdOrchestrator.TAG): Placement not found: \(placementId)")
            analyticsClient.trackUnfilledImpression(placementId: placementId, slotId: placementId, format: "interstitial")
            callback.onAdFailedToLoad(error: "Placement not found: \(placementId)")
            return false
        }

        // Get cached interstitial and register the callback wrapper in one critical
        // section so a concurrent clearCache() can't drop the wrapper mid-show
        let callbackWrapper = InterstitialCallbackWrapper(
            placementId: placementId,
            callback: callback
        )

        interstitialLock.lock()
        guard let cached = interstitialCache.removeValue(forKey: placementId) else {
            interstitialLock.unlock()
            BCLogger.warning("\(AdOrchestrator.TAG): No preloaded interstitial for: \(placementId)")
            analyticsClient.trackUnfilledImpression(placementId: placementId, slotId: placement.id, format: placement.format, gamAdUnit: placement.gamAdUnit)
            callback.onAdFailedToLoad(error: "No preloaded ad available")
            return false
        }
        interstitialCallbackWrappers[placementId] = callbackWrapper
        interstitialLock.unlock()

        // Create adapter delegate wrapper
        let delegateWrapper = InterstitialDelegateAdapterWrapper(
            placementId: placementId,
            callbackWrapper: callbackWrapper,
            analyticsClient: analyticsClient,
            placementConfig: placement
        )
        callbackWrapper.delegateWrapper = delegateWrapper

        // Show the ad (GMA presentation must happen on the main thread)
        let showAd = { [googleAdsAdapter] in
            googleAdsAdapter.showInterstitialAd(
                cached.ad,
                from: viewController,
                placementConfig: placement,
                delegate: delegateWrapper,
                loadId: cached.loadId
            )
        }
        if Thread.isMainThread {
            showAd()
        } else {
            DispatchQueue.main.async(execute: showAd)
        }

        return true
    }

    /**
     * Check if an interstitial ad is ready to show
     *
     * - Parameter placementId: The placement ID to check
     * - Returns: true if an ad is preloaded and ready
     */
    func isInterstitialReady(placementId: String) -> Bool {
        interstitialLock.lock()
        defer { interstitialLock.unlock() }
        return interstitialCache[placementId] != nil
    }

    /**
     * Clear all cached ads
     */
    func clearCache() {
        BCLogger.debug("\(AdOrchestrator.TAG): Clearing ad cache")

        interstitialLock.lock()
        for cached in interstitialCache.values {
            analyticsClient.discardAuctionData(loadId: cached.loadId)
        }
        interstitialCache.removeAll()
        interstitialCallbackWrappers.removeAll()
        interstitialLock.unlock()

        // Destroy all active banners
        bannerLock.lock()
        for bannerView in activeBanners.values {
            googleAdsAdapter.destroyBannerAd(bannerView)
        }
        activeBanners.removeAll()
        bannerCallbackWrappers.removeAll()
        bannerLock.unlock()
    }
}

// MARK: - Callback Wrapper Classes

/**
 * Wrapper to hold banner callback and prevent deallocation
 */
private class BannerCallbackWrapper {
    let placementId: String
    var callback: BannerCallback?
    var delegateWrapper: BannerDelegateAdapterWrapper?

    init(placementId: String, callback: BannerCallback) {
        self.placementId = placementId
        self.callback = callback
    }
}

/**
 * Wrapper to hold interstitial callback and prevent deallocation
 */
private class InterstitialCallbackWrapper {
    let placementId: String
    var callback: InterstitialCallback?
    var delegateWrapper: InterstitialDelegateAdapterWrapper?

    init(placementId: String, callback: InterstitialCallback) {
        self.placementId = placementId
        self.callback = callback
    }
}

// MARK: - Delegate Adapter Wrappers

/**
 * Adapts BannerAdDelegate to BannerCallback
 */
private class BannerDelegateAdapterWrapper: BannerAdDelegate {
    let placementId: String
    weak var callbackWrapper: BannerCallbackWrapper?
    let analyticsClient: AnalyticsClient?
    let placementConfig: PlacementConfig?

    init(placementId: String, callbackWrapper: BannerCallbackWrapper, analyticsClient: AnalyticsClient? = nil, placementConfig: PlacementConfig? = nil) {
        self.placementId = placementId
        self.callbackWrapper = callbackWrapper
        self.analyticsClient = analyticsClient
        self.placementConfig = placementConfig
    }

    func bannerAdDidLoad(_ bannerView: GoogleMobileAds.BannerView) {
        callbackWrapper?.callback?.onAdLoaded()
    }

    func bannerAd(_ bannerView: GoogleMobileAds.BannerView, didFailToLoadWithError error: Error) {
        if let config = placementConfig {
            analyticsClient?.trackUnfilledImpression(
                placementId: config.placementId,
                slotId: config.id,
                format: config.format,
                gamAdUnit: config.gamAdUnit
            )
        }
        callbackWrapper?.callback?.onAdFailedToLoad(error: error.localizedDescription)
    }

    func bannerAdDidRecordClick(_ bannerView: GoogleMobileAds.BannerView) {
        callbackWrapper?.callback?.onAdClicked()
    }

    func bannerAdDidRecordImpression(_ bannerView: GoogleMobileAds.BannerView) {
        callbackWrapper?.callback?.onAdImpression()
    }
}

/**
 * Adapts InterstitialAdDelegate to InterstitialCallback
 */
private class InterstitialDelegateAdapterWrapper: InterstitialAdDelegate {
    let placementId: String
    weak var callbackWrapper: InterstitialCallbackWrapper?
    let analyticsClient: AnalyticsClient?
    let placementConfig: PlacementConfig?

    init(placementId: String, callbackWrapper: InterstitialCallbackWrapper, analyticsClient: AnalyticsClient? = nil, placementConfig: PlacementConfig? = nil) {
        self.placementId = placementId
        self.callbackWrapper = callbackWrapper
        self.analyticsClient = analyticsClient
        self.placementConfig = placementConfig
    }

    func interstitialAdDidLoad(_ interstitialAd: GoogleMobileAds.InterstitialAd) {
        callbackWrapper?.callback?.onAdLoaded()
    }

    func interstitialAd(didFailToLoadWithError error: Error) {
        if let config = placementConfig {
            analyticsClient?.trackUnfilledImpression(
                placementId: config.placementId,
                slotId: config.id,
                format: config.format,
                gamAdUnit: config.gamAdUnit
            )
        }
        callbackWrapper?.callback?.onAdFailedToLoad(error: error.localizedDescription)
    }

    func interstitialAdDidPresent(_ interstitialAd: GoogleMobileAds.InterstitialAd) {
        callbackWrapper?.callback?.onAdShowed()
    }

    func interstitialAdDidDismiss(_ interstitialAd: GoogleMobileAds.InterstitialAd) {
        callbackWrapper?.callback?.onAdDismissed()
    }

    func interstitialAdDidRecordClick(_ interstitialAd: GoogleMobileAds.InterstitialAd) {
        callbackWrapper?.callback?.onAdClicked()
    }

    func interstitialAdDidRecordImpression(_ interstitialAd: GoogleMobileAds.InterstitialAd) {
        // Impression tracked by GoogleAdsAdapter
    }
}
