import Foundation
import UIKit

/**
 * Analytics client for tracking ad events
 *
 * AnalyticsClient sends all ad-related events to the BigCrunch pipeline:
 * - Page/screen views → `/pageviews`
 * - Ad impressions (includes request + auction + revenue) → `/impressions`
 * - Ad clicks → `/clicks`
 * - Ad viewability → `/viewability`
 * - User engagement → `/engagement`
 *
 * All events are fire-and-forget (non-blocking) and sent asynchronously.
 * Failures are logged but do not propagate to the caller.
 */
internal class AnalyticsClient {

    private let httpClient: HTTPClient
    private let baseURL: String

    /// Nil UUID for fields that require UUID format but have no value
    private static let nilUUID = "00000000-0000-0000-0000-000000000000"

    /// Track impression contexts for correlating events
    private var impressionContexts: [String: ImpressionContext] = [:]
    private let contextLock = NSLock()

    /// Per-placement auction data from S2S responses (set by AdOrchestrator, consumed by trackAdImpression)
    // Auction data keyed by per-load ID (not placementId) so concurrent loads and
    // banner refreshes for the same placement can't clobber each other's attribution
    private var auctionDataByLoadId: [String: AuctionData] = [:]

    /// Previous screen name for referrer tracking
    private var previousScreenName: String?

    /// Current page URL (set by trackScreenView, used by all events)
    private var currentPageUrl: String = ""

    /// Current custom dimensions (set by trackScreenView, used by all events)
    private var currentCustomDimensions: [String: String] = [:]

    /// Current account type (set by setAccountType, used by all events)
    private var currentAcctType: String = "guest"

    /// Batching for impressions (250ms delay)
    private var impressionBatch: [ImpressionBatchEvent] = []
    private var impressionBatchTimer: Timer?
    private let impressionBatchLock = NSLock()

    /// Pending impressions waiting for ILRD revenue data (keyed by placementId)
    private var pendingImpressions: [String: PendingImpression] = [:]
    private let ILRD_TIMEOUT: TimeInterval = 0.5  // 500ms

    /// ILRD revenue that arrived before the impression-record callback (keyed by placementId).
    /// GMA does not guarantee paid-event vs. impression-record ordering, so we buffer briefly.
    /// `googleAuctionWon` indicates whether AdX/Google demand outbid the S2S line item.
    private var earlyIlrdRevenue: [String: (revenueCpm: Double, googleAuctionWon: Bool, expiresAt: Date)] = [:]
    private let EARLY_ILRD_TTL: TimeInterval = 2.0  // 2s

    /// Bidder/demand-channel labels used when GMA's paid event reports that Google's
    /// auction (AdX/Open Bidding/etc.) outbid the S2S line item we sent via `hb_pb` targeting.
    private static let GOOGLE_AUCTION_BIDDER = "google_ad_exchange"
    private static let GOOGLE_AUCTION_DEMAND_CHANNEL = "Google Ad Exchange"

    private struct PendingImpression {
        let event: ImpressionBatchEvent
        let record: ImpressionRecord
        let workItem: DispatchWorkItem
    }

    /// Batching for viewability (250ms delay)
    private var viewabilityBatch: [ViewabilityEvent] = []
    private var viewabilityBatchTimer: Timer?
    private let viewabilityBatchLock = NSLock()

    init(httpClient: HTTPClient, baseURL: String) {
        self.httpClient = httpClient
        self.baseURL = baseURL
    }

    // MARK: - Session Context

    private var sessionManager: SessionManager {
        return SessionManager.shared
    }

    private var propertyId: String {
        return BigCrunchAds.propertyId
    }

    // MARK: - Event Sending

    /**
     * Send an event to a specific endpoint (fire-and-forget)
     * Wraps single event in an array as required by server
     */
    private func sendEvent<T: Encodable>(_ event: T, endpoint: String) {
        Task {
            do {
                let encoder = JSONEncoder()
                // Wrap single event in array as server expects arrays
                let eventArray = [event]
                let data = try encoder.encode(eventArray)
                let json = String(data: data, encoding: .utf8)!
                let url = "\(baseURL)/\(endpoint)"

                BCLogger.debug("Sending event to: \(endpoint)")

                let result = await httpClient.post(url: url, body: json)

                switch result {
                case .success:
                    BCLogger.verbose("Event sent successfully: \(endpoint)")
                case .failure(let error):
                    BCLogger.warning("Failed to send event: \(endpoint) - \(error)")
                }
            } catch {
                BCLogger.error("Error sending event: \(error)")
            }
        }
    }

    // MARK: - Common Event Fields

    /**
     * Get common web schema fields (device context, attribution, etc.)
     * Returns a tuple with all the common fields needed for events
     */
    private func getCommonEventFields() -> (
        browser: String,
        device: String,
        os: String,
        country: String,
        region: String,
        sessionSource: String,
        sessionMedium: String,
        utmSource: String?,
        utmMedium: String?,
        utmCampaign: String?,
        utmTerm: String?,
        utmContent: String?
    ) {
        let deviceContext = DeviceContext.shared
        let webFields = deviceContext.getWebSchemaFields()

        return (
            browser: webFields["browser"] as! String,
            device: webFields["device"] as! String,
            os: webFields["os"] as! String,
            country: webFields["country"] as! String,
            region: webFields["region"] as! String,
            sessionSource: sessionManager.sessionSource,
            sessionMedium: sessionManager.sessionMedium,
            utmSource: sessionManager.utmSource,
            utmMedium: sessionManager.utmMedium,
            utmCampaign: sessionManager.utmCampaign,
            utmTerm: sessionManager.utmTerm,
            utmContent: sessionManager.utmContent
        )
    }

    // MARK: - Page View Tracking

    /**
     * Generate the default page URL from screen name
     */
    private func generatePageUrl(_ screenName: String) -> String {
        let appName = BigCrunchAds.getAppConfig()?.appName ?? "app"
        let sanitizedAppName = appName.lowercased()
            .replacingOccurrences(of: " ", with: "-")
            .addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? appName
        let encodedScreen = screenName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? screenName
        return "https://\(sanitizedAppName).mobile.app/\(encodedScreen)"
    }

    /**
     * Track a screen view
     *
     * - Parameters:
     *   - screenName: Name of the screen being viewed
     *   - options: Optional overrides for page URL, metadata, and custom dimensions
     */
    func trackScreenView(_ screenName: String, options: ScreenViewOptions? = nil) {
        // Start new page view and get IDs
        let pageId = sessionManager.startPageView()

        // Resolve page URL: explicit override > auto-generated
        let pageUrl = options?.pageUrl ?? generatePageUrl(screenName)
        currentPageUrl = pageUrl
        currentCustomDimensions = options?.customDimensions ?? [:]

        // Get common event fields
        let common = getCommonEventFields()

        // Get GAM network code from config if available
        let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""

        // Region must be 2 chars or empty per backend validation
        let regionCode = common.region.count == 2 ? common.region : ""

        let event = PageViewEvent(
            payloadVersion: DeviceContext.SDK_VERSION,
            configVersion: 1,
            browserTimestamp: sessionManager.getCurrentTimestamp(),
            sessionId: sessionManager.sessionId,
            userId: sessionManager.userId,
            propertyId: propertyId,
            newUser: sessionManager.isNewUser,
            pageId: pageId,
            sessionDepth: sessionManager.sessionDepth,
            pageUrl: pageUrl,
            pageSearch: "",
            pageReferrer: "",  // page_referrer must be valid URL or empty
            browser: common.browser,
            device: common.device,
            os: common.os,
            country: common.country,
            region: regionCode,
            sessionSource: common.sessionSource,
            sessionMedium: common.sessionMedium,
            utmSource: common.utmSource ?? "",
            utmMedium: common.utmMedium ?? "",
            utmCampaign: common.utmCampaign ?? "",
            utmTerm: common.utmTerm ?? "",
            utmContent: common.utmContent ?? "",
            gclid: "",
            fbclid: "",
            acctType: currentAcctType,
            diiSource: "",
            gamNetworkCode: gamNetworkCode,
            amznPubId: AnalyticsClient.nilUUID,  // amzn_pub_id must be valid UUID
            customDimensions: currentCustomDimensions,
            pageMetaData: options?.pageMeta
        )

        sendEvent(event, endpoint: "pageviews")

        // Update previous screen for next referrer
        previousScreenName = screenName
    }

    /**
     * Track an ad request
     *
     * - Parameters:
     *   - placementId: Placement identifier
     *   - format: Ad format (banner, interstitial, rewarded)
     */
    func trackAdRequest(placementId: String, format: String) {
        // For now, this is a placeholder as ad requests are tracked via createImpressionContext
        // We may expand this in the future to track requests that don't result in impressions
        print("[BigCrunchAds] Ad request tracked: \(placementId) format: \(format)")
    }

    // MARK: - Impression Context Management

    /**
     * Create and store an impression context for tracking
     *
     * Call this when starting to load an ad to generate an impression ID.
     */
    func createImpressionContext(
        placementId: String,
        slotId: String,
        gamAdUnit: String,
        format: String,
        width: Int? = nil,
        height: Int? = nil
    ) -> ImpressionContext {
        let context = ImpressionContext(
            placementId: placementId,
            slotId: slotId,
            gamAdUnit: gamAdUnit,
            format: format,
            width: width,
            height: height
        )

        contextLock.lock()
        impressionContexts[context.impressionId] = context
        contextLock.unlock()

        BCLogger.debug("Created impression context: \(context.impressionId) for \(placementId)")

        return context
    }

    /**
     * Update auction data for an impression context
     */
    func updateAuctionData(impressionId: String, auctionData: AuctionData) {
        contextLock.lock()
        impressionContexts[impressionId]?.auctionData = auctionData
        contextLock.unlock()

        BCLogger.debug("Updated auction data for impression: \(impressionId)")
    }

    /**
     * Get impression context by ID
     */
    func getImpressionContext(_ impressionId: String) -> ImpressionContext? {
        contextLock.lock()
        defer { contextLock.unlock() }
        return impressionContexts[impressionId]
    }

    // MARK: - Auction Data Storage

    /**
     * Store auction data for a placement (called by AdOrchestrator after S2S demand fetch)
     */
    func setAccountType(_ accountType: String) {
        currentAcctType = accountType
    }

    func setAuctionData(loadId: String, auctionData: AuctionData) {
        contextLock.lock()
        auctionDataByLoadId[loadId] = auctionData
        contextLock.unlock()
        BCLogger.debug("Stored auction data for load: \(loadId) (channel: \(auctionData.demandChannel ?? "unknown"))")
    }

    /**
     * Consume auction data for a load (returns and removes stored data)
     */
    private func consumeAuctionData(loadId: String) -> AuctionData? {
        contextLock.lock()
        let data = auctionDataByLoadId.removeValue(forKey: loadId)
        contextLock.unlock()
        return data
    }

    /**
     * Discard stored auction data for a load that will never produce an impression
     * (failed load or failed presentation). Without this, failed loads would leak entries.
     */
    func discardAuctionData(loadId: String) {
        contextLock.lock()
        let removed = auctionDataByLoadId.removeValue(forKey: loadId)
        contextLock.unlock()
        if removed != nil {
            BCLogger.debug("Discarded auction data for load: \(loadId)")
        }
    }

    /**
     * Compute min_bid_to_win: $0.01 above the higher of second-highest bid or floor price
     */
    private func computeMinBidToWin(_ auctionData: AuctionData?) -> Double {
        guard let data = auctionData else { return 0.0 }
        let baseline = max(data.secondHighestBid ?? 0.0, data.floorPrice ?? 0.0)
        return baseline > 0 ? baseline + 0.01 : 0.0
    }

    // MARK: - Impression Tracking

    /**
     * Track an ad impression with auction data
     *
     * - Parameter context: The impression context with all tracking data
     */
    func trackAdImpression(context: ImpressionContext) {
        let adSize: String
        if let width = context.width, let height = context.height {
            adSize = "\(width)x\(height)"
        } else {
            adSize = ""
        }

        // Get common event fields
        let common = getCommonEventFields()

        // Get GAM network code from config if available
        let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""

        // Region must be 2 chars or empty per backend validation
        let regionCode = common.region.count == 2 ? common.region : ""

        // auction_id is required and must be a valid UUID - generate one if not provided
        let auctionId = context.auctionData.auctionId?.isEmpty == false
            ? context.auctionData.auctionId!
            : UUID().uuidString

        let effectiveAuctionData = context.auctionData

        // Create the impression record
        let impressionRecord = ImpressionRecord(
            slotId: context.slotId,
            gamUnit: context.gamAdUnit,
            gamPriceBucket: effectiveAuctionData.gamPriceBucket ?? "",
            impressionId: context.impressionId,
            auctionId: auctionId,  // Must be valid UUID
            refreshCount: 0,
            adBidder: effectiveAuctionData.bidder ?? "",
            adSize: adSize,
            adPrice: effectiveAuctionData.bidPriceCpm ?? 0.0,
            adFloorPrice: effectiveAuctionData.floorPrice ?? 0.0,
            minBidToWin: computeMinBidToWin(effectiveAuctionData),
            advertiserId: "",
            campaignId: "",
            lineItemId: "",
            creativeId: effectiveAuctionData.creativeId ?? "",
            adAmznbid: "",
            adAmznp: "",
            adDemandType: context.format,
            demandChannel: effectiveAuctionData.demandChannel ?? "Google Ad Exchange",
            customDimensions: [:]
        )

        // Wrap in batch event with session context
        let batchEvent = ImpressionBatchEvent(
            payloadVersion: DeviceContext.SDK_VERSION,
            configVersion: 1,
            browserTimestamp: sessionManager.getCurrentTimestamp(),
            sessionId: sessionManager.sessionId,
            userId: sessionManager.userId,
            propertyId: propertyId,
            newUser: sessionManager.isNewUser,
            pageId: sessionManager.getOrCreatePageId(),
            sessionDepth: sessionManager.sessionDepth,
            pageUrl: currentPageUrl,
            pageSearch: "",
            pageReferrer: "",
            browser: common.browser,
            device: common.device,
            os: common.os,
            country: common.country,
            region: regionCode,
            sessionSource: common.sessionSource,
            sessionMedium: common.sessionMedium,
            utmSource: common.utmSource ?? "",
            utmMedium: common.utmMedium ?? "",
            utmCampaign: common.utmCampaign ?? "",
            utmTerm: common.utmTerm ?? "",
            utmContent: common.utmContent ?? "",
            gclid: "",
            fbclid: "",
            acctType: currentAcctType,
            diiSource: "",
            gamNetworkCode: gamNetworkCode,
            amznPubId: AnalyticsClient.nilUUID,  // amzn_pub_id must be valid UUID
            customDimensions: currentCustomDimensions,
            impressions: [impressionRecord]
        )

        // Defer sending impression to wait for ILRD revenue data
        deferImpression(placementId: context.placementId, record: impressionRecord, event: batchEvent)
    }

    /**
     * Track an ad impression (simple version with auction data lookup)
     */
    func trackAdImpression(
        placementId: String,
        slotId: String,
        format: String,
        gamAdUnit: String = "",
        adSize: String = "",
        refreshCount: Int = 0,
        advertiserId: String? = nil,
        campaignId: String? = nil,
        lineItemId: String? = nil,
        creativeId: String? = nil,
        loadId: String? = nil
    ) {
        // Get common event fields
        let common = getCommonEventFields()

        // Get GAM network code from config if available
        let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""

        // Region must be 2 chars or empty per backend validation
        let regionCode = common.region.count == 2 ? common.region : ""

        // Look up stored auction data from S2S response for this specific load
        let auctionData = loadId.flatMap { consumeAuctionData(loadId: $0) }

        // auction_id: use S2S auction ID if available, otherwise generate one
        let auctionId = auctionData?.auctionId?.isEmpty == false
            ? auctionData!.auctionId!
            : UUID().uuidString

        // Create the impression record with auction data fields populated
        let impressionRecord = ImpressionRecord(
            slotId: slotId,
            gamUnit: gamAdUnit,
            gamPriceBucket: auctionData?.gamPriceBucket ?? "",
            impressionId: UUID().uuidString,
            auctionId: auctionId,
            refreshCount: refreshCount,
            adBidder: auctionData?.bidder ?? "",
            adSize: adSize,
            adPrice: auctionData?.bidPriceCpm ?? 0.0,
            adFloorPrice: auctionData?.floorPrice ?? 0.0,
            minBidToWin: computeMinBidToWin(auctionData),
            advertiserId: advertiserId ?? "",
            campaignId: campaignId ?? "",
            lineItemId: lineItemId ?? "",
            creativeId: auctionData?.creativeId ?? creativeId ?? "",
            adAmznbid: "",
            adAmznp: "",
            adDemandType: format,
            demandChannel: auctionData?.demandChannel ?? "Google Ad Exchange",
            customDimensions: [:]
        )

        // Wrap in batch event with session context
        let batchEvent = ImpressionBatchEvent(
            payloadVersion: DeviceContext.SDK_VERSION,
            configVersion: 1,
            browserTimestamp: sessionManager.getCurrentTimestamp(),
            sessionId: sessionManager.sessionId,
            userId: sessionManager.userId,
            propertyId: propertyId,
            newUser: sessionManager.isNewUser,
            pageId: sessionManager.getOrCreatePageId(),
            sessionDepth: sessionManager.sessionDepth,
            pageUrl: currentPageUrl,
            pageSearch: "",
            pageReferrer: "",
            browser: common.browser,
            device: common.device,
            os: common.os,
            country: common.country,
            region: regionCode,
            sessionSource: common.sessionSource,
            sessionMedium: common.sessionMedium,
            utmSource: common.utmSource ?? "",
            utmMedium: common.utmMedium ?? "",
            utmCampaign: common.utmCampaign ?? "",
            utmTerm: common.utmTerm ?? "",
            utmContent: common.utmContent ?? "",
            gclid: "",
            fbclid: "",
            acctType: currentAcctType,
            diiSource: "",
            gamNetworkCode: gamNetworkCode,
            amznPubId: AnalyticsClient.nilUUID,  // amzn_pub_id must be valid UUID
            customDimensions: currentCustomDimensions,
            impressions: [impressionRecord]
        )

        // Defer sending impression to wait for ILRD revenue data
        deferImpression(placementId: placementId, record: impressionRecord, event: batchEvent)
    }

    // MARK: - Unfilled Impression Tracking

    /// Track an unfilled impression (ad failed to load)
    /// Sends to the same /impressions endpoint with bidder="unfilled" and price=0
    func trackUnfilledImpression(
        placementId: String,
        slotId: String,
        format: String,
        gamAdUnit: String = ""
    ) {
        // Get common event fields
        let common = getCommonEventFields()

        // Get GAM network code from config if available
        let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""

        // Region must be 2 chars or empty per backend validation
        let regionCode = common.region.count == 2 ? common.region : ""

        let impressionRecord = ImpressionRecord(
            slotId: slotId,
            gamUnit: gamAdUnit,
            gamPriceBucket: "",
            impressionId: UUID().uuidString,
            auctionId: UUID().uuidString,
            refreshCount: 0,
            adBidder: "unfilled",
            adSize: "",
            adPrice: 0.0,
            adFloorPrice: 0.0,
            minBidToWin: 0.0,
            advertiserId: "",
            campaignId: "",
            lineItemId: "",
            creativeId: "",
            adAmznbid: "",
            adAmznp: "",
            adDemandType: format,
            demandChannel: "",
            customDimensions: [:]
        )

        let batchEvent = ImpressionBatchEvent(
            payloadVersion: DeviceContext.SDK_VERSION,
            configVersion: 1,
            browserTimestamp: sessionManager.getCurrentTimestamp(),
            sessionId: sessionManager.sessionId,
            userId: sessionManager.userId,
            propertyId: propertyId,
            newUser: sessionManager.isNewUser,
            pageId: sessionManager.getOrCreatePageId(),
            sessionDepth: sessionManager.sessionDepth,
            pageUrl: currentPageUrl,
            pageSearch: "",
            pageReferrer: "",
            browser: common.browser,
            device: common.device,
            os: common.os,
            country: common.country,
            region: regionCode,
            sessionSource: common.sessionSource,
            sessionMedium: common.sessionMedium,
            utmSource: common.utmSource ?? "",
            utmMedium: common.utmMedium ?? "",
            utmCampaign: common.utmCampaign ?? "",
            utmTerm: common.utmTerm ?? "",
            utmContent: common.utmContent ?? "",
            gclid: "",
            fbclid: "",
            acctType: currentAcctType,
            diiSource: "",
            gamNetworkCode: gamNetworkCode,
            amznPubId: AnalyticsClient.nilUUID,
            customDimensions: currentCustomDimensions,
            impressions: [impressionRecord]
        )

        // Unfilled impressions go directly to the batch (no ILRD deferral needed)
        batchImpressionEvent(batchEvent)
        BCLogger.debug("Tracked unfilled impression for \(placementId)")
    }

    // MARK: - Click Tracking

    /**
     * Track an ad click with impression context
     *
     * - Parameters:
     *   - impressionId: The impression that was clicked
     *   - placementId: Placement ID
     *   - format: Ad format
     */
    func trackAdClick(impressionId: String, placementId: String, slotId: String, format: String) {
        // Get common event fields
        let common = getCommonEventFields()

        // Get GAM network code from config if available
        let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""

        // Region must be 2 chars or empty per backend validation
        let regionCode = common.region.count == 2 ? common.region : ""

        // Create nested click data per backend schema
        // Note: customDimensions values must be string arrays per backend validation
        let clickData = ClickData(
            clickId: UUID().uuidString,
            slotId: slotId,
            impressionId: impressionId.isEmpty ? UUID().uuidString : impressionId,  // Must be valid UUID
            refreshCount: 0,
            adAmznp: "",
            adBidder: "",
            adSize: "",
            advertiserId: "",
            campaignId: "",
            lineItemId: "",
            creativeId: "",
            adDemandType: "",
            demandChannel: "",
            customDimensions: [:]  // Empty dict - values would be [String] arrays
        )

        let event = ClickEvent(
            payloadVersion: DeviceContext.SDK_VERSION,
            configVersion: 1,
            browserTimestamp: sessionManager.getCurrentTimestamp(),
            sessionId: sessionManager.sessionId,
            userId: sessionManager.userId,
            propertyId: propertyId,
            newUser: sessionManager.isNewUser,
            pageId: sessionManager.getOrCreatePageId(),
            sessionDepth: sessionManager.sessionDepth,
            pageUrl: currentPageUrl,
            pageSearch: "",
            pageReferrer: "",
            browser: common.browser,
            device: common.device,
            os: common.os,
            country: common.country,
            region: regionCode,
            sessionSource: common.sessionSource,
            sessionMedium: common.sessionMedium,
            utmSource: common.utmSource ?? "",
            utmMedium: common.utmMedium ?? "",
            utmCampaign: common.utmCampaign ?? "",
            utmTerm: common.utmTerm ?? "",
            utmContent: common.utmContent ?? "",
            gclid: "",
            fbclid: "",
            acctType: currentAcctType,
            diiSource: "",
            gamNetworkCode: gamNetworkCode,
            amznPubId: AnalyticsClient.nilUUID,
            customDimensions: currentCustomDimensions,
            click: clickData
        )

        sendEvent(event, endpoint: "clicks")
    }

    /**
     * Track an ad click (simple version without impression ID)
     */
    func trackAdClick(placementId: String, slotId: String, format: String) {
        trackAdClick(impressionId: "", placementId: placementId, slotId: slotId, format: format)
    }

    // MARK: - Viewability Tracking

    /**
     * Track ad viewability with impression context
     *
     * - Parameters:
     *   - impressionId: The impression that became viewable (must be valid UUID)
     *   - placementId: Placement ID (used as slot_id)
     *   - format: Ad format (not sent directly, used for logging)
     *   - viewableTimeMs: Time the ad was viewable in milliseconds (not in current schema)
     *   - percentVisible: Percentage of ad that was visible (not in current schema)
     */
    func trackAdViewable(
        impressionId: String,
        placementId: String,
        slotId: String,
        format: String,
        viewableTimeMs: Int64,
        percentVisible: Int
    ) {
        // Get common event fields
        let common = getCommonEventFields()

        // Get GAM network code from config if available
        let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""

        // Region must be 2 chars or empty per backend validation
        let regionCode = common.region.count == 2 ? common.region : ""

        // Create viewability data with nested structure per backend schema
        let viewabilityData = ViewabilityData(
            slotId: slotId,
            impressionId: impressionId.isEmpty ? UUID().uuidString : impressionId,  // Must be valid UUID
            refreshCount: 0,
            adAmznp: "",
            adBidder: "",
            adSize: "",
            advertiserId: "",
            campaignId: "",
            lineItemId: "",
            creativeId: "",
            adDemandType: "",
            demandChannel: "",
            customDimensions: [:]
        )

        let event = ViewabilityEvent(
            payloadVersion: DeviceContext.SDK_VERSION,
            configVersion: 1,
            browserTimestamp: sessionManager.getCurrentTimestamp(),
            sessionId: sessionManager.sessionId,
            userId: sessionManager.userId,
            propertyId: propertyId,
            newUser: sessionManager.isNewUser,
            pageId: sessionManager.getOrCreatePageId(),
            sessionDepth: sessionManager.sessionDepth,
            pageUrl: currentPageUrl,
            pageSearch: "",
            pageReferrer: "",
            browser: common.browser,
            device: common.device,
            os: common.os,
            country: common.country,
            region: regionCode,
            sessionSource: common.sessionSource,
            sessionMedium: common.sessionMedium,
            utmSource: common.utmSource ?? "",
            utmMedium: common.utmMedium ?? "",
            utmCampaign: common.utmCampaign ?? "",
            utmTerm: common.utmTerm ?? "",
            utmContent: common.utmContent ?? "",
            gclid: "",
            fbclid: "",
            acctType: currentAcctType,
            diiSource: "",
            gamNetworkCode: gamNetworkCode,
            amznPubId: AnalyticsClient.nilUUID,
            viewability: [viewabilityData]
        )

        batchViewabilityEvent(event)
    }

    /**
     * Track ad viewability (simple version without metrics)
     */
    func trackAdViewable(placementId: String, slotId: String, format: String) {
        // Get common event fields
        let common = getCommonEventFields()

        // Get GAM network code from config if available
        let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""

        // Region must be 2 chars or empty per backend validation
        let regionCode = common.region.count == 2 ? common.region : ""

        // Create viewability data with nested structure per backend schema
        let viewabilityData = ViewabilityData(
            slotId: slotId,
            impressionId: UUID().uuidString,  // Generate UUID since none provided
            refreshCount: 0,
            adAmznp: "",
            adBidder: "",
            adSize: "",
            advertiserId: "",
            campaignId: "",
            lineItemId: "",
            creativeId: "",
            adDemandType: "",
            demandChannel: "",
            customDimensions: [:]
        )

        let event = ViewabilityEvent(
            payloadVersion: DeviceContext.SDK_VERSION,
            configVersion: 1,
            browserTimestamp: sessionManager.getCurrentTimestamp(),
            sessionId: sessionManager.sessionId,
            userId: sessionManager.userId,
            propertyId: propertyId,
            newUser: sessionManager.isNewUser,
            pageId: sessionManager.getOrCreatePageId(),
            sessionDepth: sessionManager.sessionDepth,
            pageUrl: currentPageUrl,
            pageSearch: "",
            pageReferrer: "",
            browser: common.browser,
            device: common.device,
            os: common.os,
            country: common.country,
            region: regionCode,
            sessionSource: common.sessionSource,
            sessionMedium: common.sessionMedium,
            utmSource: common.utmSource ?? "",
            utmMedium: common.utmMedium ?? "",
            utmCampaign: common.utmCampaign ?? "",
            utmTerm: common.utmTerm ?? "",
            utmContent: common.utmContent ?? "",
            gclid: "",
            fbclid: "",
            acctType: currentAcctType,
            diiSource: "",
            gamNetworkCode: gamNetworkCode,
            amznPubId: AnalyticsClient.nilUUID,
            viewability: [viewabilityData]
        )
        batchViewabilityEvent(event)
    }

    // MARK: - Engagement Tracking

    /**
     * Track user engagement with content
     *
     * - Parameters:
     *   - engagedTime: Time actively engaged in seconds
     *   - timeOnPage: Total time on page/screen in seconds
     *   - scrollDepth: Maximum scroll depth percentage (0-100)
     */
    func trackEngagement(engagedTime: Int, timeOnPage: Int, scrollDepth: Int = 0) {
        // Get common event fields
        let common = getCommonEventFields()

        // Get GAM network code from config if available
        let gamNetworkCode = BigCrunchAds.getConfigManager().getGamNetworkCode() ?? ""

        // Region must be 2 chars or empty per backend validation
        let regionCode = common.region.count == 2 ? common.region : ""

        let event = EngagementEvent(
            payloadVersion: DeviceContext.SDK_VERSION,
            configVersion: 1,
            browserTimestamp: sessionManager.getCurrentTimestamp(),
            sessionId: sessionManager.sessionId,
            userId: sessionManager.userId,
            propertyId: propertyId,
            newUser: sessionManager.isNewUser,
            pageId: sessionManager.getOrCreatePageId(),
            sessionDepth: sessionManager.sessionDepth,
            pageUrl: currentPageUrl,
            pageSearch: "",
            pageReferrer: "",
            browser: common.browser,
            device: common.device,
            os: common.os,
            country: common.country,
            region: regionCode,
            sessionSource: common.sessionSource,
            sessionMedium: common.sessionMedium,
            utmSource: common.utmSource ?? "",
            utmMedium: common.utmMedium ?? "",
            utmCampaign: common.utmCampaign ?? "",
            utmTerm: common.utmTerm ?? "",
            utmContent: common.utmContent ?? "",
            gclid: "",
            fbclid: "",
            acctType: currentAcctType,
            diiSource: "",
            gamNetworkCode: gamNetworkCode,
            amznPubId: AnalyticsClient.nilUUID,
            customDimensions: [:],  // Engagement uses array-valued custom dimensions per backend schema
            engagedTime: engagedTime,
            timeOnPage: timeOnPage,
            scrollDepth: scrollDepth
        )

        sendEvent(event, endpoint: "engagement")
    }

    // MARK: - Deferred Impression (ILRD Revenue)

    /// Hold an impression for up to 500ms waiting for ILRD revenue.
    /// If ILRD already arrived before this impression (rare but possible due to GMA
    /// not guaranteeing callback order), apply the buffered value and flush immediately.
    private func deferImpression(placementId: String, record: ImpressionRecord, event: ImpressionBatchEvent) {
        var existingToFlush: ImpressionBatchEvent? = nil
        var buffered: (revenueCpm: Double, googleAuctionWon: Bool)? = nil

        contextLock.lock()

        // Check for ILRD that arrived before this impression
        if let entry = earlyIlrdRevenue.removeValue(forKey: placementId),
           entry.expiresAt > Date() {
            buffered = (revenueCpm: entry.revenueCpm, googleAuctionWon: entry.googleAuctionWon)
        }

        // If there's already a pending impression for this placement (e.g., banner refresh),
        // collect it for flushing after we release the lock
        if let existing = pendingImpressions.removeValue(forKey: placementId) {
            existing.workItem.cancel()
            existingToFlush = existing.event
        }

        contextLock.unlock()

        // Flush old pending impression outside the lock
        if let oldEvent = existingToFlush {
            batchImpressionEvent(oldEvent)
        }

        // Fast path: ILRD already arrived, apply revenue and send immediately
        if let buf = buffered {
            let updatedRecord = AnalyticsClient.applyIlrd(to: record, revenueCpm: buf.revenueCpm, googleAuctionWon: buf.googleAuctionWon)
            var updatedEvent = event
            updatedEvent.impressions = [updatedRecord]
            batchImpressionEvent(updatedEvent)
            BCLogger.debug("Applied buffered early ILRD for \(placementId): \(buf.revenueCpm) (googleAuctionWon=\(buf.googleAuctionWon))")
            return
        }

        // Slow path: defer waiting for ILRD
        let workItem = DispatchWorkItem { [weak self] in
            self?.flushPendingImpression(placementId: placementId)
        }

        contextLock.lock()
        pendingImpressions[placementId] = PendingImpression(
            event: event,
            record: record,
            workItem: workItem
        )
        contextLock.unlock()

        // Schedule timeout on main queue
        DispatchQueue.main.asyncAfter(deadline: .now() + ILRD_TIMEOUT, execute: workItem)

        BCLogger.debug("Deferred impression for \(placementId), waiting for ILRD (500ms timeout)")
    }

    /// Flush a pending impression (called by timeout or ILRD arrival)
    private func flushPendingImpression(placementId: String) {
        contextLock.lock()
        guard let pending = pendingImpressions.removeValue(forKey: placementId) else {
            contextLock.unlock()
            return
        }
        contextLock.unlock()

        batchImpressionEvent(pending.event)
        BCLogger.debug("Flushed pending impression for \(placementId) (ad_price: \(pending.record.adPrice))")
    }

    /// Handle ILRD revenue data from Google's paid event handler.
    /// If a pending impression exists, update it; otherwise buffer the value briefly
    /// in case the impression-record callback hasn't fired yet.
    ///
    /// - Parameter googleAuctionWon: True when GMA reports `precise`/`estimated` precision,
    ///   meaning Google's auction (AdX/Open Bidding) provided the value. False when the
    ///   value came from publisher-provided `hb_pb` targeting (S2S line item won) or is
    ///   `unknown` precision.
    func handleIlrdRevenue(placementId: String, revenueCpm: Double, googleAuctionWon: Bool) {
        BCLogger.debug("ILRD revenue received for \(placementId): \(revenueCpm) (googleAuctionWon=\(googleAuctionWon))")

        contextLock.lock()
        if let pending = pendingImpressions.removeValue(forKey: placementId) {
            pending.workItem.cancel()
            contextLock.unlock()

            let updatedRecord = AnalyticsClient.applyIlrd(to: pending.record, revenueCpm: revenueCpm, googleAuctionWon: googleAuctionWon)
            var updatedEvent = pending.event
            updatedEvent.impressions = [updatedRecord]

            batchImpressionEvent(updatedEvent)
            BCLogger.debug("Flushed impression for \(placementId) with ILRD revenue: \(revenueCpm) bidder=\(updatedRecord.adBidder)")
            return
        }

        // No pending impression yet — buffer for the impression-record callback that will arrive shortly
        earlyIlrdRevenue[placementId] = (
            revenueCpm: revenueCpm,
            googleAuctionWon: googleAuctionWon,
            expiresAt: Date().addingTimeInterval(EARLY_ILRD_TTL)
        )
        contextLock.unlock()
        BCLogger.debug("Buffered early ILRD for \(placementId): \(revenueCpm) (waiting for impression callback)")
    }

    /// Apply ILRD price + winner attribution to an impression record.
    /// When Google's auction won, override the bidder/demand-channel that was pre-set
    /// from the S2S response (which would have been the S2S bidder name).
    private static func applyIlrd(to record: ImpressionRecord, revenueCpm: Double, googleAuctionWon: Bool) -> ImpressionRecord {
        var updated = record
        updated.adPrice = revenueCpm
        if googleAuctionWon {
            updated.adBidder = GOOGLE_AUCTION_BIDDER
            updated.demandChannel = GOOGLE_AUCTION_DEMAND_CHANNEL
        }
        return updated
    }

    // MARK: - Batching Helpers

    /**
     * Add impression event to batch queue
     *
     * Batches impressions for 250ms before sending to reduce network requests.
     */
    private func batchImpressionEvent(_ event: ImpressionBatchEvent) {
        impressionBatchLock.lock()
        impressionBatch.append(event)

        // Start timer if not already running
        if impressionBatchTimer == nil {
            impressionBatchTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { [weak self] _ in
                self?.flushImpressionBatch()
            }
        }

        impressionBatchLock.unlock()
    }

    /**
     * Flush all batched impression events
     *
     * Merges events with the same pageId into a single object with combined impressions array.
     */
    private func flushImpressionBatch() {
        impressionBatchLock.lock()
        let events = impressionBatch
        impressionBatch.removeAll()
        impressionBatchTimer?.invalidate()
        impressionBatchTimer = nil
        impressionBatchLock.unlock()

        if events.isEmpty { return }

        // Group by pageId and merge impressions into single objects
        var merged: [String: ImpressionBatchEvent] = [:]
        for event in events {
            if var existing = merged[event.pageId] {
                existing.impressions.append(contentsOf: event.impressions)
                merged[event.pageId] = existing
            } else {
                merged[event.pageId] = event
            }
        }

        let mergedEvents = Array(merged.values)

        Task {
            do {
                let encoder = JSONEncoder()
                let data = try encoder.encode(mergedEvents)
                let json = String(data: data, encoding: .utf8)!
                let url = "\(baseURL)/impressions"

                BCLogger.debug("Sending \(mergedEvents.count) batched impression groups (\(events.count) total impressions)")

                let result = await httpClient.post(url: url, body: json)

                switch result {
                case .success:
                    BCLogger.verbose("Impression batch sent successfully")
                case .failure(let error):
                    BCLogger.warning("Failed to send impression batch: \(error)")
                }
            } catch {
                BCLogger.error("Error sending impression batch: \(error)")
            }
        }
    }

    /**
     * Add viewability event to batch queue
     *
     * Batches viewability events for 250ms before sending to reduce network requests.
     */
    private func batchViewabilityEvent(_ event: ViewabilityEvent) {
        viewabilityBatchLock.lock()
        viewabilityBatch.append(event)

        // Start timer if not already running
        if viewabilityBatchTimer == nil {
            viewabilityBatchTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { [weak self] _ in
                self?.flushViewabilityBatch()
            }
        }

        viewabilityBatchLock.unlock()
    }

    /**
     * Flush all batched viewability events
     *
     * Merges events with the same pageId into a single object with combined viewability array.
     */
    private func flushViewabilityBatch() {
        viewabilityBatchLock.lock()
        let events = viewabilityBatch
        viewabilityBatch.removeAll()
        viewabilityBatchTimer?.invalidate()
        viewabilityBatchTimer = nil
        viewabilityBatchLock.unlock()

        if events.isEmpty { return }

        // Group by pageId and merge viewability into single objects
        var merged: [String: ViewabilityEvent] = [:]
        for event in events {
            if var existing = merged[event.pageId] {
                existing.viewability.append(contentsOf: event.viewability)
                merged[event.pageId] = existing
            } else {
                merged[event.pageId] = event
            }
        }

        let mergedEvents = Array(merged.values)

        Task {
            do {
                let encoder = JSONEncoder()
                let data = try encoder.encode(mergedEvents)
                let json = String(data: data, encoding: .utf8)!
                let url = "\(baseURL)/viewability"

                BCLogger.debug("Sending \(mergedEvents.count) batched viewability groups (\(events.count) total)")

                let result = await httpClient.post(url: url, body: json)

                switch result {
                case .success:
                    BCLogger.verbose("Viewability batch sent successfully")
                case .failure(let error):
                    BCLogger.warning("Failed to send viewability batch: \(error)")
                }
            } catch {
                BCLogger.error("Error sending viewability batch: \(error)")
            }
        }
    }

    // MARK: - Cleanup

    /**
     * Remove an impression context (call when ad is destroyed)
     */
    func removeImpressionContext(_ impressionId: String) {
        contextLock.lock()
        impressionContexts.removeValue(forKey: impressionId)
        contextLock.unlock()
    }

    /**
     * Clear all impression contexts
     */
    func clearImpressionContexts() {
        contextLock.lock()
        impressionContexts.removeAll()
        contextLock.unlock()
    }
}
