#!/usr/bin/env python
"""
This is the Python client library for GrowthBook, the open-source
feature flagging and A/B testing platform.
More info at https://www.growthbook.io
"""
import atexit
import sys
import json
import threading
import logging
import warnings

from abc import ABC, abstractmethod
from typing import Optional, Any, Set, Tuple, List, Dict, Callable

from .common_types import ( EvaluationContext, 
    Experiment, 
    FeatureResult, 
    Feature,
    GlobalContext, 
    Options, 
    Result, StackContext, 
    UserContext, 
    AbstractStickyBucketService,
    FeatureRule
)

# Only require typing_extensions if using Python 3.7 or earlier
if sys.version_info >= (3, 8):
    from typing import TypedDict
else:
    from typing_extensions import TypedDict

from base64 import b64decode
from time import time
import aiohttp
import asyncio

from aiohttp.client_exceptions import ClientConnectorError, ClientResponseError, ClientPayloadError
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from urllib3 import PoolManager, Timeout

from .core import _getHashValue, eval_feature as core_eval_feature, run_experiment

logger = logging.getLogger("growthbook")

def decrypt(encrypted_str: str, key_str: str) -> str:
    iv_str, ct_str = encrypted_str.split(".", 2)

    key = b64decode(key_str)
    iv = b64decode(iv_str)
    ct = b64decode(ct_str)

    cipher = Cipher(algorithms.AES128(key), modes.CBC(iv))
    decryptor = cipher.decryptor()

    decrypted = decryptor.update(ct) + decryptor.finalize()

    unpadder = padding.PKCS7(128).unpadder()
    bytestring = unpadder.update(decrypted) + unpadder.finalize()

    return bytestring.decode("utf-8")

class AbstractFeatureCache(ABC):
    @abstractmethod
    def get(self, key: str) -> Optional[Dict]:
        pass

    @abstractmethod
    def set(self, key: str, value: Dict, ttl: int) -> None:
        pass

    def clear(self) -> None:
        pass


class CacheEntry(object):
    def __init__(self, value: Dict, ttl: int) -> None:
        self.value = value
        self.ttl = ttl
        self.expires = time() + ttl

    def update(self, value: Dict):
        self.value = value
        self.expires = time() + self.ttl


class InMemoryFeatureCache(AbstractFeatureCache):
    def __init__(self) -> None:
        self.cache: Dict[str, CacheEntry] = {}

    def get(self, key: str) -> Optional[Dict]:
        if key in self.cache:
            entry = self.cache[key]
            if entry.expires >= time():
                return entry.value
        return None

    def set(self, key: str, value: Dict, ttl: int) -> None:
        if key in self.cache:
            self.cache[key].update(value)
        else:
            self.cache[key] = CacheEntry(value, ttl)

    def clear(self) -> None:
        self.cache.clear()

class InMemoryStickyBucketService(AbstractStickyBucketService):
    def __init__(self) -> None:
        self.docs: Dict[str, Dict] = {}

    def get_assignments(self, attributeName: str, attributeValue: str) -> Optional[Dict]:
        return self.docs.get(self.get_key(attributeName, attributeValue), None)

    def save_assignments(self, doc: Dict) -> None:
        self.docs[self.get_key(doc["attributeName"], doc["attributeValue"])] = doc

    def destroy(self) -> None:
        self.docs.clear()


class SSEClient:
    def __init__(self, api_host, client_key, on_event, reconnect_delay=5, headers=None, timeout=30):
        self.api_host = api_host
        self.client_key = client_key

        self.on_event = on_event
        self.reconnect_delay = reconnect_delay
        self.timeout = timeout

        self._sse_session = None
        self._sse_thread = None
        self._loop = None

        self.is_running = False

        self.headers = {
            "Accept": "application/json; q=0.5, text/event-stream",
            "Cache-Control": "no-cache",
            "Accept-Encoding": "gzip, deflate, br",
        }

        if headers:
            self.headers.update(headers)

    def connect(self):
        if self.is_running:
            logger.debug("Streaming session is already running.")
            return

        self.is_running = True
        self._sse_thread = threading.Thread(target=self._run_sse_channel, daemon=True)
        self._sse_thread.start()
        atexit.register(self.disconnect)

    def disconnect(self, timeout=10):
        """Gracefully disconnect with timeout"""
        logger.debug("Initiating SSE client disconnect")
        self.is_running = False
        
        if self._loop and self._loop.is_running():
            future = asyncio.run_coroutine_threadsafe(self._stop_session(timeout), self._loop)
            try:
                # Wait with timeout for clean shutdown
                future.result(timeout=timeout)
                logger.debug("SSE session stopped cleanly")
            except Exception as e:
                logger.warning(f"Error during SSE disconnect: {e}")
                # Force close the loop if clean shutdown failed
                if self._loop and self._loop.is_running():
                    try:
                        self._loop.call_soon_threadsafe(self._loop.stop)
                    except Exception:
                        pass

        if self._sse_thread:
            self._sse_thread.join(timeout=timeout)
            if self._sse_thread.is_alive():
                logger.warning("SSE thread did not terminate gracefully within timeout")
            else:
                logger.debug("SSE thread terminated")

        logger.debug("Streaming session disconnected")

    def _get_sse_url(self, api_host: str, client_key: str) -> str:
        api_host = (api_host or "https://cdn.growthbook.io").rstrip("/")
        return f"{api_host}/sub/{client_key}"

    async def _init_session(self):
        url = self._get_sse_url(self.api_host, self.client_key)
        
        try:
            while self.is_running:
                try:
                    async with aiohttp.ClientSession(headers=self.headers, 
                        timeout=aiohttp.ClientTimeout(connect=self.timeout)) as session:
                        self._sse_session = session

                        async with session.get(url) as response:
                            response.raise_for_status()
                            await self._process_response(response)
                except ClientResponseError as e:
                    logger.error(f"Streaming error, closing connection: {e.status} {e.message}")
                    self.is_running = False
                    break
                except (ClientConnectorError, ClientPayloadError) as e:
                    logger.error(f"Streaming error: {e}")
                    await self._wait_for_reconnect()
                    if not self.is_running:
                        break  # type: ignore[unreachable]
                except TimeoutError:
                    logger.warning(f"Streaming connection timed out after {self.timeout} seconds.")
                    await self._wait_for_reconnect()
                    if not self.is_running:
                        break  # type: ignore[unreachable]
                except asyncio.CancelledError:
                    logger.debug("SSE session cancelled")
                    break
                finally:
                    await self._close_session()
        except asyncio.CancelledError:
            logger.debug("SSE _init_session cancelled")
            pass
        finally:
            # Ensure session is closed on any exit
            await self._close_session()

    async def _process_response(self, response):
        event_data = {}
        try:
            async for line in response.content:
                # Check for cancellation before processing each line
                if not self.is_running:
                    logger.debug("SSE processing stopped - is_running is False")
                    break
                    
                decoded_line = line.decode('utf-8').strip()
                if decoded_line.startswith("event:"):
                    event_data['type'] = decoded_line[len("event:"):].strip()
                elif decoded_line.startswith("data:"):
                    event_data['data'] = event_data.get('data', '') + f"\n{decoded_line[len('data:'):].strip()}"
                elif not decoded_line:
                    if 'type' in event_data and 'data' in event_data:
                        try:
                            self.on_event(event_data)
                        except Exception as e:
                            logger.warning(f"Error in event handler: {e}")
                    event_data = {}
            
            # Process any remaining event data
            if 'type' in event_data and 'data' in event_data:
                try:
                    self.on_event(event_data)
                except Exception as e:
                    logger.warning(f"Error in final event handler: {e}")
        except asyncio.CancelledError:
            logger.debug("SSE response processing cancelled")
            raise
        except Exception as e:
            logger.warning(f"Error processing SSE response: {e}")
            raise

    async def _wait_for_reconnect(self):
        logger.info(f"Attempting to reconnect streaming in {self.reconnect_delay} seconds")
        try:
            await asyncio.sleep(self.reconnect_delay)
        except asyncio.CancelledError:
            logger.debug("Reconnect wait cancelled")
            raise

    async def _close_session(self):
        if self._sse_session:
            await self._sse_session.close()
            logger.debug("Streaming session closed.")

    def _run_sse_channel(self):
        self._loop = asyncio.new_event_loop()
        
        try:
            self._loop.run_until_complete(self._init_session())
        except asyncio.CancelledError:
            pass
        finally:
            self._loop.run_until_complete(self._loop.shutdown_asyncgens())
            self._loop.close()

    async def _stop_session(self, timeout=10):
        """Stop the SSE session and cancel all tasks with timeout"""
        logger.debug("Stopping SSE session")
        
        # Close the session first
        if self._sse_session and not self._sse_session.closed:
            try:
                await self._sse_session.close()
                logger.debug("SSE session closed")
            except Exception as e:
                logger.warning(f"Error closing SSE session: {e}")

        # Cancel all tasks in this loop
        if self._loop and self._loop.is_running():
            try:
                # Get all tasks for this specific loop
                tasks = [task for task in asyncio.all_tasks(self._loop) 
                        if not task.done() and task is not asyncio.current_task(self._loop)]
                
                if tasks:
                    logger.debug(f"Cancelling {len(tasks)} SSE tasks")
                    # Cancel all tasks
                    for task in tasks:
                        task.cancel()
                    
                    # Wait for tasks to complete with timeout
                    try:
                        await asyncio.wait_for(
                            asyncio.gather(*tasks, return_exceptions=True),
                            timeout=timeout
                        )
                        logger.debug("All SSE tasks cancelled successfully")
                    except asyncio.TimeoutError:
                        logger.warning("Some SSE tasks did not cancel within timeout")
                    except Exception as e:
                        logger.warning(f"Error during task cancellation: {e}")
            except Exception as e:
                logger.warning(f"Error during SSE task cleanup: {e}")

from collections import OrderedDict

# ... (imports)

class FeatureRepository(object):
    def __init__(self) -> None:
        self.cache: AbstractFeatureCache = InMemoryFeatureCache()
        self.http: Optional[PoolManager] = None
        self.http_connect_timeout: Optional[int] = None
        self.http_read_timeout: Optional[int] = None
        self.sse_client: Optional[SSEClient] = None
        self._feature_update_callbacks: List[Callable[[Dict], None]] = []
        
        # Background refresh support
        self._refresh_thread: Optional[threading.Thread] = None
        self._refresh_stop_event = threading.Event()
        self._refresh_lock = threading.Lock()
        
        # ETag cache for bandwidth optimization
        # Using OrderedDict for LRU cache (max 100 entries)
        self._etag_cache: OrderedDict[str, Tuple[str, Dict[str, Any]]] = OrderedDict()
        self._max_etag_entries = 100
        self._etag_lock = threading.Lock()

    def set_cache(self, cache: AbstractFeatureCache) -> None:
        self.cache = cache

    def clear_cache(self):
        self.cache.clear()

    def save_in_cache(self, key: str, res, ttl: int = 600):
        self.cache.set(key, res, ttl)

    def add_feature_update_callback(self, callback: Callable[[Dict], None]) -> None:
        """Add a callback to be notified when features are updated due to cache expiry"""
        if callback not in self._feature_update_callbacks:
            self._feature_update_callbacks.append(callback)

    def remove_feature_update_callback(self, callback: Callable[[Dict], None]) -> None:
        """Remove a feature update callback"""
        if callback in self._feature_update_callbacks:
            self._feature_update_callbacks.remove(callback)

    def _notify_feature_update_callbacks(self, features_data: Dict) -> None:
        """Notify all registered callbacks about feature updates"""
        for callback in self._feature_update_callbacks:
            try:
                callback(features_data)
            except Exception as e:
                logger.warning(f"Error in feature update callback: {e}")

    # Loads features with an in-memory cache in front using stale-while-revalidate approach
    def load_features(
        self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600
    ) -> Optional[Dict]:
        if not client_key:
            raise ValueError("Must specify `client_key` to refresh features")
        
        key = api_host + "::" + client_key

        cached = self.cache.get(key)
        if not cached:
            res = self._fetch_features(api_host, client_key, decryption_key)
            if res is not None:
                self.cache.set(key, res, ttl)
                logger.debug("Fetched features from API, stored in cache")
                # Notify callbacks about fresh features
                self._notify_feature_update_callbacks(res)
                return res
        return cached
    
    
    async def load_features_async(
        self, api_host: str, client_key: str, decryption_key: str = "", ttl: int = 600
    ) -> Optional[Dict]:
        key = api_host + "::" + client_key

        cached = self.cache.get(key)
        if not cached:
            res = await self._fetch_features_async(api_host, client_key, decryption_key)
            if res is not None:
                self.cache.set(key, res, ttl)
                logger.debug("Fetched features from API, stored in cache")
                # Notify callbacks about fresh features
                self._notify_feature_update_callbacks(res)
                return res
        return cached
    
    @property
    def user_agent_suffix(self) -> Optional[str]:
        return getattr(self, "_user_agent_suffix", None)
        
    @user_agent_suffix.setter
    def user_agent_suffix(self, value: Optional[str]) -> None:
        self._user_agent_suffix = value

    # Perform the GET request (separate method for easy mocking)
    def _get(self, url: str, headers: Optional[Dict[str, str]] = None):
        timeout = None
        if self.http_connect_timeout and self.http_read_timeout:
            timeout = Timeout(connect=self.http_connect_timeout, read=self.http_read_timeout)
        self.http = self.http or PoolManager(timeout=timeout)
        return self.http.request("GET", url, headers=headers or {})
    
    def _get_headers(self, client_key: str, existing_headers: Dict[str, str] = None) -> Dict[str, str]:
        headers = existing_headers or {}
        headers['Accept-Encoding'] = "gzip, deflate"
        
        # Add User-Agent with optional suffix
        ua = "Gb-Python"
        ua += f"-{self.user_agent_suffix}" if self.user_agent_suffix else f"-{client_key[-4:]}"
        headers['User-Agent'] = ua
            
        return headers

    def _fetch_and_decode(self, api_host: str, client_key: str) -> Optional[Dict]:
        url = self._get_features_url(api_host, client_key)
        headers = self._get_headers(client_key)
        logger.debug(f"Fetching features from {url} with headers {headers}")
        
        # Check if we have a cached ETag for this URL
        cached_etag = None
        cached_data = None
        with self._etag_lock:
            if url in self._etag_cache:
                # Move to end (mark as recently used)
                self._etag_cache.move_to_end(url)
                cached_etag, cached_data = self._etag_cache[url]
                headers['If-None-Match'] = cached_etag
                logger.debug(f"Using cached ETag for request: {cached_etag[:20]}...")
            else:
                logger.debug(f"No ETag cache found for URL: {url}")
        
        try:
            r = self._get(url, headers)
            
            # Handle 304 Not Modified - content hasn't changed
            if r.status == 304:
                logger.debug(f"ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
                if cached_data is not None:
                    logger.debug(f"Returning cached response ({len(str(cached_data))} bytes)")
                    return cached_data
                else:
                    logger.warning("Received 304 but no cached data available")
                    return None
            
            if r.status >= 400:
                logger.warning(
                    "Failed to fetch features, received status code %d", r.status
                )
                return None
            
            decoded = json.loads(r.data.decode("utf-8"))
            
            # Store the new ETag if present
            response_etag = r.headers.get('ETag')
            if response_etag:
                with self._etag_lock:
                    self._etag_cache[url] = (response_etag, decoded)
                    # Enforce max size
                    if len(self._etag_cache) > self._max_etag_entries:
                        self._etag_cache.popitem(last=False)
                        
                    if cached_etag:
                        logger.debug(f"ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
                    else:
                        logger.debug(f"New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
                    logger.debug(f"ETag cache now contains {len(self._etag_cache)} entries")
            else:
                logger.debug("No ETag header in response")
            
            return decoded  # type: ignore[no-any-return]
        except Exception as e:
            logger.error(f"Failed to decode feature JSON from GrowthBook API: {e}")
            return None
        
    async def _fetch_and_decode_async(self, api_host: str, client_key: str) -> Optional[Dict]:
        url = self._get_features_url(api_host, client_key)
        headers = self._get_headers(client_key=client_key)
        logger.debug(f"[Async] Fetching features from {url} with headers {headers}")
        
        # Check if we have a cached ETag for this URL
        cached_etag = None
        cached_data = None
        with self._etag_lock:
            if url in self._etag_cache:
                # Move to end (mark as recently used)
                self._etag_cache.move_to_end(url)
                cached_etag, cached_data = self._etag_cache[url]
                headers['If-None-Match'] = cached_etag
                logger.debug(f"[Async] Using cached ETag for request: {cached_etag[:20]}...")
            else:
                logger.debug(f"[Async] No ETag cache found for URL: {url}")
        
        try:
            async with aiohttp.ClientSession() as session:
                async with session.get(url, headers=headers) as response:
                    # Handle 304 Not Modified - content hasn't changed
                    if response.status == 304:
                        logger.debug(f"[Async] ETag match! Server returned 304 Not Modified - using cached data (saved bandwidth)")
                        if cached_data is not None:
                            logger.debug(f"[Async] Returning cached response ({len(str(cached_data))} bytes)")
                            return cached_data
                        else:
                            logger.warning("[Async] Received 304 but no cached data available")
                            return None
                    
                    if response.status >= 400:
                        logger.warning("Failed to fetch features, received status code %d", response.status)
                        return None
                    
                    decoded = await response.json()
                    
                    # Store the new ETag if present
                    response_etag = response.headers.get('ETag')
                    if response_etag:
                        with self._etag_lock:
                            self._etag_cache[url] = (response_etag, decoded)
                            # Enforce max size
                            if len(self._etag_cache) > self._max_etag_entries:
                                self._etag_cache.popitem(last=False)
                                
                            if cached_etag:
                                logger.debug(f"[Async] ETag updated: {cached_etag[:20]}... -> {response_etag[:20]}...")
                            else:
                                logger.debug(f"[Async] New ETag cached: {response_etag[:20]}... ({len(str(decoded))} bytes)")
                            logger.debug(f"[Async] ETag cache now contains {len(self._etag_cache)} entries")
                    else:
                        logger.debug("[Async] No ETag header in response")
                    
                    return decoded  # type: ignore[no-any-return]
        except aiohttp.ClientError as e:
            logger.warning(f"HTTP request failed: {e}")
            return None
        except Exception as e:
            logger.error(f"Failed to decode feature JSON from GrowthBook API: {e}")
            return None
        
    def decrypt_response(self, data, decryption_key: str):
        if "encryptedFeatures" in data:
            if not decryption_key:
                raise ValueError("Must specify decryption_key")
            try:
                decryptedFeatures = decrypt(data["encryptedFeatures"], decryption_key)
                data['features'] = json.loads(decryptedFeatures)
                del data['encryptedFeatures']
            except Exception:
                logger.warning(
                    "Failed to decrypt features from GrowthBook API response"
                )
                return None
        elif "features" not in data:
            logger.warning("GrowthBook API response missing features")
        
        if "encryptedSavedGroups" in data:
            if not decryption_key:
                raise ValueError("Must specify decryption_key")
            try:
                decryptedFeatures = decrypt(data["encryptedSavedGroups"], decryption_key)
                data['savedGroups'] = json.loads(decryptedFeatures)
                del data['encryptedSavedGroups']
                return data
            except Exception:
                logger.warning(
                    "Failed to decrypt saved groups from GrowthBook API response"
                )
            
        return data

    # Fetch features from the GrowthBook API
    def _fetch_features(
        self, api_host: str, client_key: str, decryption_key: str = ""
    ) -> Optional[Dict]:
        decoded = self._fetch_and_decode(api_host, client_key)
        if not decoded:
            return None

        data = self.decrypt_response(decoded, decryption_key)

        return data  # type: ignore[no-any-return]
        
    async def _fetch_features_async(
        self, api_host: str, client_key: str, decryption_key: str = ""
    ) -> Optional[Dict]:
        decoded = await self._fetch_and_decode_async(api_host, client_key)
        if not decoded:
            return None

        data = self.decrypt_response(decoded, decryption_key)

        return data  # type: ignore[no-any-return]


    def startAutoRefresh(self, api_host, client_key, cb, streaming_timeout=30):
        if not client_key:
            raise ValueError("Must specify `client_key` to start features streaming")
        self.sse_client = self.sse_client or SSEClient(api_host=api_host, client_key=client_key, on_event=cb, timeout=streaming_timeout)
        self.sse_client.connect()

    def stopAutoRefresh(self, timeout=10):
        """Stop auto refresh with timeout"""
        if self.sse_client:
            self.sse_client.disconnect(timeout=timeout)
            self.sse_client = None
    
    def start_background_refresh(self, api_host: str, client_key: str, decryption_key: str, ttl: int = 600, refresh_interval: int = 300) -> None:
        """Start periodic background refresh task"""

        if not client_key:
            raise ValueError("Must specify `client_key` to refresh features")

        with self._refresh_lock:
            if self._refresh_thread is not None:
                return  # Already running
            
            self._refresh_stop_event.clear()
            self._refresh_thread = threading.Thread(
                target=self._background_refresh_worker,
                args=(api_host, client_key, decryption_key, ttl, refresh_interval),
                daemon=True
            )
            self._refresh_thread.start()
            logger.debug("Started background refresh task")
    
    def _background_refresh_worker(self, api_host: str, client_key: str, decryption_key: str, ttl: int, refresh_interval: int) -> None:
        """Worker method for periodic background refresh"""
        while not self._refresh_stop_event.is_set():
            try:
                # Wait for the refresh interval or stop event
                if self._refresh_stop_event.wait(refresh_interval):
                    break  # Stop event was set
                
                logger.debug("Background refresh for Features - started")
                res = self._fetch_features(api_host, client_key, decryption_key)
                if res is not None:
                    cache_key = api_host + "::" + client_key
                    self.cache.set(cache_key, res, ttl)
                    logger.debug("Background refresh completed")
                    # Notify callbacks about fresh features
                    self._notify_feature_update_callbacks(res)
                else:
                    logger.warning("Background refresh failed")
            except Exception as e:
                logger.warning(f"Background refresh error: {e}")
    
    def stop_background_refresh(self) -> None:
        """Stop background refresh task"""
        self._refresh_stop_event.set()
        
        with self._refresh_lock:
            if self._refresh_thread is not None:
                self._refresh_thread.join(timeout=1.0)  # Wait up to 1 second
                self._refresh_thread = None
                logger.debug("Stopped background refresh task")

    @staticmethod
    def _get_features_url(api_host: str, client_key: str) -> str:
        api_host = (api_host or "https://cdn.growthbook.io").rstrip("/")
        return api_host + "/api/features/" + client_key


# Singleton instance
feature_repo = FeatureRepository()

class GrowthBook(object):
    def __init__(
        self,
        enabled: bool = True,
        attributes: dict = {},
        url: str = "",
        features: dict = {},
        qa_mode: bool = False,
        on_experiment_viewed=None,
        on_feature_usage=None,
        api_host: str = "",
        client_key: str = "",
        decryption_key: str = "",
        cache_ttl: int = 600,
        forced_variations: Optional[Dict[str, Any]] = None,
        sticky_bucket_service: Optional[AbstractStickyBucketService] = None,
        sticky_bucket_identifier_attributes: Optional[List[str]] = None,
        savedGroups: Optional[Dict[str, Any]] = None,
        streaming: bool = False,
        streaming_connection_timeout: int = 30,
        stale_while_revalidate: bool = False,
        stale_ttl: int = 300,  # 5 minutes default
        plugins: Optional[List[Any]] = None,
        skip_all_experiments: bool = False,
        # Deprecated args
        trackingCallback=None,
        qaMode: bool = False,
        user: dict = {},
        groups: dict = {},
        overrides: dict = {},
        forcedVariations: dict = {},
        http_connect_timeout: Optional[int] = None,
        http_read_timeout: Optional[int] = None,
    ):
        self._enabled = enabled
        self._attributes = attributes
        self._url = url
        self._features: Dict[str, Feature] = {}
        self._saved_groups = savedGroups if savedGroups is not None else {}
        self._api_host = api_host
        self._client_key = client_key
        self._decryption_key = decryption_key
        self._cache_ttl = cache_ttl
        self.sticky_bucket_identifier_attributes = sticky_bucket_identifier_attributes
        self.sticky_bucket_service = sticky_bucket_service
        self._sticky_bucket_assignment_docs: dict = {}
        self._using_derived_sticky_bucket_attributes = not sticky_bucket_identifier_attributes
        self._sticky_bucket_attributes: Optional[dict] = None

        self._qaMode = qa_mode or qaMode
        self._trackingCallback = on_experiment_viewed or trackingCallback
        self._featureUsageCallback = on_feature_usage
        self._skip_all_experiments = skip_all_experiments

        self._streaming = streaming
        self._streaming_timeout = streaming_connection_timeout
        self._stale_while_revalidate = stale_while_revalidate
        self._stale_ttl = stale_ttl

        # Deprecated args
        self._user = user
        self._groups = groups
        self._overrides = overrides
        self._forcedVariations = (forced_variations if forced_variations is not None else forcedVariations) if forced_variations is not None or forcedVariations else {}

        self._tracked: Dict[str, Any] = {}
        self._assigned: Dict[str, Any] = {}
        self._subscriptions: Set[Any] = set()
        self._is_updating_features = False

        # support plugins
        self._plugins: List[Any] = plugins if plugins is not None else []
        self._initialized_plugins: List[Any] = []

        self._global_ctx = GlobalContext(
            options=Options(
                url=self._url,
                api_host=self._api_host,
                client_key=self._client_key,
                decryption_key=self._decryption_key,
                cache_ttl=self._cache_ttl,
                sticky_bucket_service=self.sticky_bucket_service,
                sticky_bucket_identifier_attributes=self.sticky_bucket_identifier_attributes,
                enabled=self._enabled,
                qa_mode=self._qaMode
            ),
            features={},
            saved_groups=self._saved_groups
        )       
        # Create a user context for the current user
        self._user_ctx: UserContext = UserContext(
            url=self._url,
            attributes=self._attributes,
            groups=self._groups,
            forced_variations=self._forcedVariations,
            overrides=self._overrides,
            sticky_bucket_assignment_docs=self._sticky_bucket_assignment_docs,
            skip_all_experiments=self._skip_all_experiments
        )

        if features:
            self.set_features(features)

        # Register for automatic feature updates when cache expires
        if self._client_key:
            feature_repo.add_feature_update_callback(self._on_feature_update)

        self._initialize_plugins()

        if self._streaming:
            self.load_features()
            self.startAutoRefresh()
        elif self._stale_while_revalidate:
            # Start background refresh task for stale-while-revalidate
            self.load_features()  # Initial load
            feature_repo.start_background_refresh(
                self._api_host, self._client_key, self._decryption_key, 
                self._cache_ttl, self._stale_ttl
            )

        if http_connect_timeout and http_read_timeout:
            feature_repo.http_connect_timeout = http_connect_timeout
            feature_repo.http_read_timeout = http_read_timeout

    def _on_feature_update(self, features_data: Dict) -> None:
        """Callback to handle automatic feature updates from FeatureRepository"""
        if features_data and "features" in features_data:
            self.set_features(features_data["features"])
        if features_data and "savedGroups" in features_data:
            self._saved_groups = features_data["savedGroups"]

    def load_features(self) -> None:
        response = feature_repo.load_features(
            self._api_host, self._client_key, self._decryption_key, self._cache_ttl
        )
        if response is not None and "features" in response.keys():
            self.set_features(response["features"])

        if response is not None and "savedGroups" in response:
            self._saved_groups = response["savedGroups"]

    async def load_features_async(self) -> None:
        if not self._client_key:
            raise ValueError("Must specify `client_key` to refresh features")

        features = await feature_repo.load_features_async(
            self._api_host, self._client_key, self._decryption_key, self._cache_ttl
        )

        if features is not None:
            if "features" in features:
                self.set_features(features["features"])
            if "savedGroups" in features:
                self._saved_groups = features["savedGroups"]
            feature_repo.save_in_cache(self._client_key, features, self._cache_ttl)

    def _features_event_handler(self, features):
        decoded = json.loads(features)
        if not decoded:
            return None
        
        data = feature_repo.decrypt_response(decoded, self._decryption_key)

        if data is not None:
            if "features" in data:
                self.set_features(data["features"])
            if "savedGroups" in data:
                self._saved_groups = data["savedGroups"]
            feature_repo.save_in_cache(self._client_key, features, self._cache_ttl)

    def _dispatch_sse_event(self, event_data):
        event_type = event_data['type']
        data = event_data['data']
        if event_type == 'features-updated':
            self.load_features()
        elif event_type == 'features':
            self._features_event_handler(data)


    def startAutoRefresh(self):
        if not self._client_key:
            raise ValueError("Must specify `client_key` to start features streaming")
       
        feature_repo.startAutoRefresh(
            api_host=self._api_host, 
            client_key=self._client_key,
            cb=self._dispatch_sse_event,
            streaming_timeout=self._streaming_timeout
        )

    def stopAutoRefresh(self, timeout=10):
        """Stop auto refresh with timeout"""
        try:
            if hasattr(feature_repo, 'sse_client') and feature_repo.sse_client:
                feature_repo.sse_client.disconnect(timeout=timeout)
            else:
                feature_repo.stopAutoRefresh()
        except Exception as e:
            logger.warning(f"Error stopping auto refresh: {e}")

    def setFeatures(self, features: dict) -> None:
        warnings.warn("setFeatures is deprecated, use set_features instead", DeprecationWarning)
        return self.set_features(features)

    def set_features(self, features: dict) -> None:
        # Prevent infinite recursion during feature updates
        self._is_updating_features = True
        try:
            self._features = {}
            for key, feature in features.items():
                if isinstance(feature, Feature):
                    self._features[key] = feature
                else:
                    self._features[key] = Feature(
                        rules=feature.get("rules", []),
                        defaultValue=feature.get("defaultValue", None),
                    )
            # Update the global context with the new features and saved groups
            self._global_ctx.features = self._features
            self._global_ctx.saved_groups = self._saved_groups
            self.refresh_sticky_buckets()
        finally:
            self._is_updating_features = False

    def getFeatures(self) -> Dict[str, Feature]:
        warnings.warn("getFeatures is deprecated, use get_features instead", DeprecationWarning)
        return self.get_features()

    def get_features(self) -> Dict[str, Feature]:
        return self._features

    def setAttributes(self, attributes: dict) -> None:
        warnings.warn("setAttributes is deprecated, use set_attributes instead", DeprecationWarning)
        return self.set_attributes(attributes)

    def set_attributes(self, attributes: dict) -> None:
        self._attributes = attributes
        self.refresh_sticky_buckets()

    def getAttributes(self) -> dict:
        warnings.warn("getAttributes is deprecated, use get_attributes instead", DeprecationWarning)
        return self.get_attributes()

    def get_attributes(self) -> dict:
        return self._attributes

    def destroy(self, timeout=10) -> None:
        """Gracefully destroy the GrowthBook instance"""
        logger.debug("Starting GrowthBook destroy process")
        
        try:
            # Clean up plugins
            logger.debug("Cleaning up plugins")
            self._cleanup_plugins()
        except Exception as e:
            logger.warning(f"Error cleaning up plugins: {e}")
        
        try:
            logger.debug("Stopping auto refresh during destroy")
            self.stopAutoRefresh(timeout=timeout)
        except Exception as e:
            logger.warning(f"Error stopping auto refresh during destroy: {e}")
        
        try:
            # Stop background refresh operations
            if self._stale_while_revalidate and self._client_key:
                feature_repo.stop_background_refresh()
        except Exception as e:
            logger.warning(f"Error stopping background refresh during destroy: {e}")
        
        try:
            # Clean up feature update callback
            if self._client_key:
                feature_repo.remove_feature_update_callback(self._on_feature_update)
        except Exception as e:
            logger.warning(f"Error removing feature update callback: {e}")
        
        # Clear all internal state
        try:
            self._subscriptions.clear()
            self._tracked.clear()
            self._assigned.clear()
            self._trackingCallback = None
            self._featureUsageCallback = None
            self._forcedVariations.clear()
            self._overrides.clear()
            self._groups.clear()
            self._attributes.clear()
            self._features.clear()
            logger.debug("GrowthBook instance destroyed successfully")
        except Exception as e:
            logger.warning(f"Error clearing internal state: {e}")

    def isOn(self, key: str) -> bool:
        warnings.warn("isOn is deprecated, use is_on instead", DeprecationWarning)
        return self.is_on(key)

    def is_on(self, key: str) -> bool:
        return self.eval_feature(key).on

    def isOff(self, key: str) -> bool:
        warnings.warn("isOff is deprecated, use is_off instead", DeprecationWarning)
        return self.is_off(key)

    def is_off(self, key: str) -> bool:
        return self.eval_feature(key).off

    def getFeatureValue(self, key: str, fallback):
        warnings.warn("getFeatureValue is deprecated, use get_feature_value instead", DeprecationWarning)
        return self.get_feature_value(key, fallback)

    def get_feature_value(self, key: str, fallback):
        res = self.eval_feature(key)
        return res.value if res.value is not None else fallback

    def evalFeature(self, key: str) -> FeatureResult:
        warnings.warn("evalFeature is deprecated, use eval_feature instead", DeprecationWarning)
        return self.eval_feature(key)
    
    def _ensure_fresh_features(self) -> None:
        """Lazy refresh: Check cache expiry and refresh if needed, but only if client_key is provided"""
        
        # Prevent infinite recursion when updating features (e.g., during sticky bucket refresh)
        if self._is_updating_features:
            return
        
        if self._streaming or self._stale_while_revalidate or not self._client_key:
            return  # Skip cache checks - SSE or background refresh handles freshness

        try:
            self.load_features()
        except Exception as e:
            logger.warning(f"Failed to refresh features: {e}")

    def _get_eval_context(self) -> EvaluationContext:
        # Lazy refresh: ensure features are fresh before evaluation
        self._ensure_fresh_features()
        
        # use the latest attributes for every evaluation.
        self._user_ctx.attributes = self._attributes
        self._user_ctx.url = self._url
        self._user_ctx.overrides = self._overrides
        # set the url for every evaluation. (unlikely to change)
        self._global_ctx.options.url = self._url
        return EvaluationContext(
            global_ctx = self._global_ctx,
            user = self._user_ctx,
            stack = StackContext(evaluated_features=set())
        )

    def eval_feature(self, key: str) -> FeatureResult:
        result = core_eval_feature(key=key, 
                                   evalContext=self._get_eval_context(), 
                                   callback_subscription=self._fireSubscriptions,
                                   tracking_cb=self._track
                                   )
        # Call feature usage callback if provided
        if self._featureUsageCallback:
            try:
                self._featureUsageCallback(key, result, self._user_ctx)
            except Exception:
                pass
        return result

    def getAllResults(self):
        warnings.warn("getAllResults is deprecated, use get_all_results instead", DeprecationWarning)
        return self.get_all_results()

    def get_all_results(self):
        return self._assigned.copy()

    def _fireSubscriptions(self, experiment: Experiment, result: Result):
        if experiment is not None:
            prev = self._assigned.get(experiment.key, None)
            if (
                not prev
                or prev["result"].inExperiment != result.inExperiment
                or prev["result"].variationId != result.variationId
            ):
                self._assigned[experiment.key] = {
                    "experiment": experiment,
                    "result": result,
                }
                for cb in self._subscriptions:
                    try:
                        cb(experiment, result)
                    except Exception:
                        pass

    def run(self, experiment: Experiment) -> Result:
        # result = self._run(experiment)
        result = run_experiment(experiment=experiment, 
                                evalContext=self._get_eval_context(),
                                tracking_cb=self._track
                                )

        self._fireSubscriptions(experiment, result)
        return result

    def subscribe(self, callback):
        self._subscriptions.add(callback)
        return lambda: self._subscriptions.remove(callback)

    def _track(self, experiment: Experiment, result: Result, user_context: UserContext) -> None:
        if not self._trackingCallback:
            return None
        key = (
            result.hashAttribute
            + str(result.hashValue)
            + experiment.key
            + str(result.variationId)
        )
        if not self._tracked.get(key):
            try:
                self._trackingCallback(experiment=experiment, result=result, user_context=user_context)
                self._tracked[key] = True
            except Exception as e:
                logger.exception(e)

    def _derive_sticky_bucket_identifier_attributes(self) -> List[str]:
        attributes = set()
        for key, feature in self._features.items():
            for rule in feature.rules:
                if rule.variations:
                    attributes.add(rule.hashAttribute or "id")
                    if rule.fallbackAttribute:
                        attributes.add(rule.fallbackAttribute)
        return list(attributes)

    def _get_sticky_bucket_attributes(self) -> dict:
        attributes: Dict[str, str] = {}
        if self._using_derived_sticky_bucket_attributes:
            self.sticky_bucket_identifier_attributes = self._derive_sticky_bucket_identifier_attributes()

        if not self.sticky_bucket_identifier_attributes:
            return attributes

        for attr in self.sticky_bucket_identifier_attributes:
            _, hash_value = _getHashValue(attr=attr, eval_context=self._get_eval_context())
            if hash_value:
                attributes[attr] = hash_value
        return attributes

    def refresh_sticky_buckets(self, force: bool = False) -> None:
        if not self.sticky_bucket_service:
            return

        attributes = self._get_sticky_bucket_attributes()
        if not force and attributes == self._sticky_bucket_attributes:
            logger.debug("Skipping refresh of sticky bucket assignments, no changes")
            return

        self._sticky_bucket_attributes = attributes
        self._sticky_bucket_assignment_docs = self.sticky_bucket_service.get_all_assignments(attributes)
        # Update the user context with the new sticky bucket assignment docs
        self._user_ctx.sticky_bucket_assignment_docs = self._sticky_bucket_assignment_docs

    def _initialize_plugins(self) -> None:
        """Initialize all plugins with this GrowthBook instance."""
        for plugin in self._plugins:
            try:
                if hasattr(plugin, 'initialize'):
                    # Plugin is a class instance with initialize method
                    plugin.initialize(self)
                    self._initialized_plugins.append(plugin)
                    logger.debug(f"Initialized plugin: {plugin.__class__.__name__}")
                elif callable(plugin):
                    # Plugin is a callable function
                    plugin(self)
                    self._initialized_plugins.append(plugin)
                    logger.debug(f"Initialized callable plugin: {plugin.__name__}")
                else:
                    logger.warning(f"Plugin {plugin} is neither callable nor has initialize method")
            except Exception as e:
                logger.error(f"Failed to initialize plugin {plugin}: {e}")

    @property
    def user_agent_suffix(self) -> Optional[str]:
        """Get the suffix appended to the User-Agent header"""
        return feature_repo.user_agent_suffix
        
    @user_agent_suffix.setter
    def user_agent_suffix(self, value: Optional[str]) -> None:
        """Set a suffix to be appended to the User-Agent header"""
        feature_repo.user_agent_suffix = value

    def _cleanup_plugins(self) -> None:
        """Cleanup all initialized plugins."""
        for plugin in self._initialized_plugins:
            try:
                if hasattr(plugin, 'cleanup'):
                    plugin.cleanup()
                    logger.debug(f"Cleaned up plugin: {plugin.__class__.__name__}")
            except Exception as e:
                logger.error(f"Error cleaning up plugin {plugin}: {e}")
        self._initialized_plugins.clear()
