# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Experiment Framework for Familiar (v1.4.0)

Provides A/B testing, feature flags, and metrics tracking:
- Feature flags for gradual rollouts
- A/B experiments with variant assignment
- Metrics collection and statistical analysis
- Experiment lifecycle management

Usage:
    from familiar.core.experiments import (
        ExperimentManager, Experiment, Variant,
        FeatureFlag, get_experiment_manager
    )

    # Feature flags
    manager = get_experiment_manager()
    manager.create_flag("new_ui", enabled_percent=50)

    if manager.is_enabled("new_ui", user_id="user-123"):
        show_new_ui()

    # A/B experiments
    experiment = manager.create_experiment(
        name="prompt_style",
        variants=[
            Variant("control", weight=50),
            Variant("concise", weight=50, config={"max_tokens": 100}),
        ]
    )

    variant = manager.get_variant("prompt_style", user_id="user-123")
    response = generate_response(style=variant.name)

    # Track metrics
    manager.track_metric("prompt_style", user_id="user-123",
                         metric="user_rating", value=5)

    # Analyze results
    results = manager.analyze("prompt_style")
    print(results.summary())
"""

import hashlib
import json
import logging
import math
import random
import statistics
import threading
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional, Set, Tuple

logger = logging.getLogger(__name__)


# ============================================================
# FEATURE FLAGS
# ============================================================


class FlagStatus(str, Enum):
    """Status of a feature flag."""

    ENABLED = "enabled"  # Always on
    DISABLED = "disabled"  # Always off
    PERCENTAGE = "percentage"  # Enabled for X% of users
    ALLOWLIST = "allowlist"  # Enabled for specific users
    DENYLIST = "denylist"  # Disabled for specific users


@dataclass
class FeatureFlag:
    """
    A feature flag for gradual rollouts.

    Supports:
    - Simple on/off
    - Percentage-based rollout
    - User allowlist/denylist
    """

    name: str
    description: str = ""
    status: FlagStatus = FlagStatus.DISABLED

    # Percentage rollout (0-100)
    enabled_percent: float = 0.0

    # User lists
    allowlist: Set[str] = field(default_factory=set)
    denylist: Set[str] = field(default_factory=set)

    # Metadata
    created_at: str = ""
    updated_at: str = ""
    created_by: str = ""

    # Tags for organization
    tags: List[str] = field(default_factory=list)

    def __post_init__(self):
        if not self.created_at:
            self.created_at = datetime.now().isoformat()
        self.updated_at = self.created_at

    def is_enabled(self, user_id: str = "") -> bool:
        """Check if flag is enabled for a user."""

        # Check denylist first
        if user_id and user_id in self.denylist:
            return False

        # Check allowlist
        if user_id and user_id in self.allowlist:
            return True

        # Check status
        if self.status == FlagStatus.ENABLED:
            return True
        elif self.status == FlagStatus.DISABLED:
            return False
        elif self.status == FlagStatus.PERCENTAGE:
            if not user_id:
                # Random for anonymous users
                return random.random() * 100 < self.enabled_percent
            # Deterministic hash-based assignment
            hash_val = int(hashlib.md5(f"{self.name}:{user_id}".encode()).hexdigest(), 16)
            return (hash_val % 100) < self.enabled_percent
        elif self.status == FlagStatus.ALLOWLIST:
            return user_id in self.allowlist
        elif self.status == FlagStatus.DENYLIST:
            return user_id not in self.denylist

        return False

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "description": self.description,
            "status": self.status.value,
            "enabled_percent": self.enabled_percent,
            "allowlist": list(self.allowlist),
            "denylist": list(self.denylist),
            "created_at": self.created_at,
            "updated_at": self.updated_at,
            "tags": self.tags,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "FeatureFlag":
        data = data.copy()
        if "status" in data:
            data["status"] = FlagStatus(data["status"])
        if "allowlist" in data:
            data["allowlist"] = set(data["allowlist"])
        if "denylist" in data:
            data["denylist"] = set(data["denylist"])
        return cls(**data)


# ============================================================
# A/B EXPERIMENTS
# ============================================================


class ExperimentStatus(str, Enum):
    """Status of an experiment."""

    DRAFT = "draft"  # Not yet running
    RUNNING = "running"  # Actively collecting data
    PAUSED = "paused"  # Temporarily stopped
    COMPLETED = "completed"  # Finished, analyzing results
    ARCHIVED = "archived"  # Archived, no longer active


@dataclass
class Variant:
    """
    A variant in an A/B experiment.

    Each variant has a weight (for traffic allocation) and
    optional configuration that can be used in the application.
    """

    name: str
    weight: float = 50.0  # Percentage of traffic
    description: str = ""
    config: Dict[str, Any] = field(default_factory=dict)

    # Control variant flag
    is_control: bool = False

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "weight": self.weight,
            "description": self.description,
            "config": self.config,
            "is_control": self.is_control,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "Variant":
        return cls(**data)


@dataclass
class Experiment:
    """
    An A/B experiment configuration.

    Defines the variants, targeting, and success metrics.
    """

    name: str
    description: str = ""
    hypothesis: str = ""  # What we're testing

    # Variants
    variants: List[Variant] = field(default_factory=list)

    # Status and lifecycle
    status: ExperimentStatus = ExperimentStatus.DRAFT

    # Targeting
    target_percent: float = 100.0  # % of eligible users to include
    target_segments: List[str] = field(default_factory=list)  # User segments

    # Success metrics
    primary_metric: str = ""  # Main metric to optimize
    secondary_metrics: List[str] = field(default_factory=list)

    # Statistical settings
    min_sample_size: int = 100  # Per variant
    confidence_level: float = 0.95  # For significance testing

    # Dates
    created_at: str = ""
    started_at: str = ""
    ended_at: str = ""

    # Metadata
    owner: str = ""
    tags: List[str] = field(default_factory=list)

    def __post_init__(self):
        if not self.created_at:
            self.created_at = datetime.now().isoformat()

        # Ensure we have at least a control variant
        if not self.variants:
            self.variants = [
                Variant("control", weight=50, is_control=True),
                Variant("treatment", weight=50),
            ]

        # Normalize weights
        self._normalize_weights()

    def _normalize_weights(self):
        """Ensure weights sum to 100."""
        total = sum(v.weight for v in self.variants)
        if total > 0 and abs(total - 100) > 0.01:
            for v in self.variants:
                v.weight = (v.weight / total) * 100

    def get_variant(self, user_id: str) -> Variant:
        """Get the variant for a user (deterministic assignment)."""
        if not self.variants:
            raise ValueError("Experiment has no variants")

        # Hash-based deterministic assignment
        hash_val = int(hashlib.md5(f"{self.name}:{user_id}".encode()).hexdigest(), 16)
        bucket = hash_val % 100

        cumulative = 0.0
        for variant in self.variants:
            cumulative += variant.weight
            if bucket < cumulative:
                return variant

        # Fallback to last variant
        return self.variants[-1]

    def get_control(self) -> Optional[Variant]:
        """Get the control variant."""
        for v in self.variants:
            if v.is_control:
                return v
        return self.variants[0] if self.variants else None

    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "description": self.description,
            "hypothesis": self.hypothesis,
            "variants": [v.to_dict() for v in self.variants],
            "status": self.status.value,
            "target_percent": self.target_percent,
            "target_segments": self.target_segments,
            "primary_metric": self.primary_metric,
            "secondary_metrics": self.secondary_metrics,
            "min_sample_size": self.min_sample_size,
            "confidence_level": self.confidence_level,
            "created_at": self.created_at,
            "started_at": self.started_at,
            "ended_at": self.ended_at,
            "owner": self.owner,
            "tags": self.tags,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "Experiment":
        data = data.copy()
        if "variants" in data:
            data["variants"] = [Variant.from_dict(v) for v in data["variants"]]
        if "status" in data:
            data["status"] = ExperimentStatus(data["status"])
        return cls(**data)


# ============================================================
# METRICS TRACKING
# ============================================================


@dataclass
class MetricEvent:
    """A single metric event."""

    experiment_name: str
    variant_name: str
    user_id: str
    metric_name: str
    value: float
    timestamp: str = ""
    metadata: Dict[str, Any] = field(default_factory=dict)

    def __post_init__(self):
        if not self.timestamp:
            self.timestamp = datetime.now().isoformat()


@dataclass
class VariantMetrics:
    """Aggregated metrics for a variant."""

    variant_name: str
    sample_size: int = 0

    # Per-metric statistics
    metrics: Dict[str, Dict[str, float]] = field(default_factory=dict)
    # metrics[metric_name] = {"count": N, "sum": X, "mean": X/N, "std": S, "min": M, "max": M}

    def add_value(self, metric_name: str, value: float):
        """Add a metric value."""
        if metric_name not in self.metrics:
            self.metrics[metric_name] = {
                "count": 0,
                "sum": 0.0,
                "sum_sq": 0.0,  # For std calculation
                "min": float("inf"),
                "max": float("-inf"),
                "values": [],  # Keep raw values for detailed analysis
            }

        m = self.metrics[metric_name]
        m["count"] += 1
        m["sum"] += value
        m["sum_sq"] += value * value
        m["min"] = min(m["min"], value)
        m["max"] = max(m["max"], value)
        m["values"].append(value)

    def get_stats(self, metric_name: str) -> Dict[str, float]:
        """Get statistics for a metric."""
        if metric_name not in self.metrics:
            return {}

        m = self.metrics[metric_name]
        count = m["count"]

        if count == 0:
            return {"count": 0}

        mean = m["sum"] / count

        # Calculate standard deviation
        if count > 1:
            variance = (m["sum_sq"] / count) - (mean * mean)
            std = math.sqrt(max(0, variance))  # Avoid negative due to float precision
        else:
            std = 0.0

        return {
            "count": count,
            "sum": m["sum"],
            "mean": mean,
            "std": std,
            "min": m["min"] if m["min"] != float("inf") else 0,
            "max": m["max"] if m["max"] != float("-inf") else 0,
        }


@dataclass
class ExperimentResults:
    """Results and analysis for an experiment."""

    experiment_name: str
    status: str = ""

    # Per-variant metrics
    variant_metrics: Dict[str, VariantMetrics] = field(default_factory=dict)

    # Statistical analysis
    is_significant: bool = False
    p_value: float = 1.0
    confidence_interval: Tuple[float, float] = (0.0, 0.0)

    # Winner determination
    winner: str = ""
    lift: float = 0.0  # % improvement over control

    # Analysis metadata
    analyzed_at: str = ""
    primary_metric: str = ""

    def __post_init__(self):
        if not self.analyzed_at:
            self.analyzed_at = datetime.now().isoformat()

    def summary(self) -> str:
        """Generate a human-readable summary."""
        lines = [
            f"Experiment: {self.experiment_name}",
            "=" * 50,
            f"Status: {self.status}",
            f"Primary Metric: {self.primary_metric}",
            "",
            "Variant Results:",
        ]

        for variant_name, vm in self.variant_metrics.items():
            stats = vm.get_stats(self.primary_metric) if self.primary_metric else {}
            lines.append(f"  {variant_name}:")
            lines.append(f"    Sample Size: {vm.sample_size}")
            if stats:
                lines.append(f"    Mean: {stats.get('mean', 0):.4f} ± {stats.get('std', 0):.4f}")

        lines.append("")
        if self.winner:
            lines.append(f"Winner: {self.winner}")
            lines.append(f"Lift: {self.lift:+.2%}")
            lines.append(
                f"Significant: {'Yes' if self.is_significant else 'No'} (p={self.p_value:.4f})"
            )
        else:
            lines.append("No clear winner yet")

        return "\n".join(lines)

    def to_dict(self) -> dict:
        return {
            "experiment_name": self.experiment_name,
            "status": self.status,
            "variant_metrics": {
                k: {
                    "variant_name": v.variant_name,
                    "sample_size": v.sample_size,
                    "metrics": {m: v.get_stats(m) for m in v.metrics},
                }
                for k, v in self.variant_metrics.items()
            },
            "is_significant": self.is_significant,
            "p_value": self.p_value,
            "winner": self.winner,
            "lift": self.lift,
            "analyzed_at": self.analyzed_at,
            "primary_metric": self.primary_metric,
        }


# ============================================================
# STATISTICAL ANALYSIS
# ============================================================


class StatisticalAnalyzer:
    """
    Statistical analysis for experiment results.

    Provides:
    - Two-sample t-test for means
    - Chi-square test for proportions
    - Confidence intervals
    - Effect size calculation
    """

    @staticmethod
    def t_test(
        values_a: List[float], values_b: List[float], confidence_level: float = 0.95
    ) -> Dict[str, float]:
        """
        Two-sample t-test (Welch's t-test).

        Returns p-value and other statistics.
        """
        n_a, n_b = len(values_a), len(values_b)

        if n_a < 2 or n_b < 2:
            return {"p_value": 1.0, "t_statistic": 0.0, "significant": False}

        mean_a = statistics.mean(values_a)
        mean_b = statistics.mean(values_b)
        var_a = statistics.variance(values_a)
        var_b = statistics.variance(values_b)

        # Welch's t-test
        se = math.sqrt(var_a / n_a + var_b / n_b)

        if se == 0:
            return {"p_value": 1.0, "t_statistic": 0.0, "significant": False}

        t_stat = (mean_a - mean_b) / se

        # Degrees of freedom (Welch-Satterthwaite)
        num = (var_a / n_a + var_b / n_b) ** 2
        denom = (var_a / n_a) ** 2 / (n_a - 1) + (var_b / n_b) ** 2 / (n_b - 1)
        df = num / denom if denom > 0 else 1

        # Approximate p-value using normal distribution for large samples
        # (For small samples, would need scipy.stats.t)
        p_value = 2 * (1 - StatisticalAnalyzer._normal_cdf(abs(t_stat)))

        # Critical value for confidence level
        alpha = 1 - confidence_level
        significant = p_value < alpha

        return {
            "p_value": p_value,
            "t_statistic": t_stat,
            "degrees_of_freedom": df,
            "mean_a": mean_a,
            "mean_b": mean_b,
            "difference": mean_b - mean_a,
            "significant": significant,
        }

    @staticmethod
    def _normal_cdf(x: float) -> float:
        """Approximate normal CDF using error function approximation."""
        # Approximation of the cumulative distribution function
        return 0.5 * (1 + math.erf(x / math.sqrt(2)))

    @staticmethod
    def proportion_test(
        successes_a: int,
        total_a: int,
        successes_b: int,
        total_b: int,
        confidence_level: float = 0.95,
    ) -> Dict[str, float]:
        """
        Two-proportion z-test.

        For comparing conversion rates or success rates.
        """
        if total_a == 0 or total_b == 0:
            return {"p_value": 1.0, "z_statistic": 0.0, "significant": False}

        p_a = successes_a / total_a
        p_b = successes_b / total_b

        # Pooled proportion
        p_pooled = (successes_a + successes_b) / (total_a + total_b)

        # Standard error
        se = math.sqrt(p_pooled * (1 - p_pooled) * (1 / total_a + 1 / total_b))

        if se == 0:
            return {"p_value": 1.0, "z_statistic": 0.0, "significant": False}

        z_stat = (p_a - p_b) / se
        p_value = 2 * (1 - StatisticalAnalyzer._normal_cdf(abs(z_stat)))

        alpha = 1 - confidence_level
        significant = p_value < alpha

        return {
            "p_value": p_value,
            "z_statistic": z_stat,
            "proportion_a": p_a,
            "proportion_b": p_b,
            "difference": p_b - p_a,
            "relative_lift": (p_b - p_a) / p_a if p_a > 0 else 0,
            "significant": significant,
        }

    @staticmethod
    def confidence_interval(
        values: List[float], confidence_level: float = 0.95
    ) -> Tuple[float, float]:
        """Calculate confidence interval for mean."""
        if len(values) < 2:
            mean = values[0] if values else 0
            return (mean, mean)

        n = len(values)
        mean = statistics.mean(values)
        std = statistics.stdev(values)

        # Z-score for confidence level
        z_scores = {0.90: 1.645, 0.95: 1.96, 0.99: 2.576}
        z = z_scores.get(confidence_level, 1.96)

        margin = z * (std / math.sqrt(n))

        return (mean - margin, mean + margin)

    @staticmethod
    def effect_size(values_a: List[float], values_b: List[float]) -> float:
        """
        Calculate Cohen's d effect size.

        Returns:
            < 0.2: negligible
            0.2-0.5: small
            0.5-0.8: medium
            > 0.8: large
        """
        if len(values_a) < 2 or len(values_b) < 2:
            return 0.0

        mean_a = statistics.mean(values_a)
        mean_b = statistics.mean(values_b)

        # Pooled standard deviation
        var_a = statistics.variance(values_a)
        var_b = statistics.variance(values_b)
        n_a, n_b = len(values_a), len(values_b)

        pooled_std = math.sqrt(((n_a - 1) * var_a + (n_b - 1) * var_b) / (n_a + n_b - 2))

        if pooled_std == 0:
            return 0.0

        return (mean_b - mean_a) / pooled_std

    @staticmethod
    def sample_size_needed(
        baseline_rate: float,
        min_detectable_effect: float,
        confidence_level: float = 0.95,
        power: float = 0.80,
    ) -> int:
        """
        Calculate sample size needed per variant.

        Args:
            baseline_rate: Expected rate for control (e.g., 0.10 for 10%)
            min_detectable_effect: Minimum relative change to detect (e.g., 0.10 for 10%)
            confidence_level: Statistical confidence (default 0.95)
            power: Statistical power (default 0.80)

        Returns:
            Sample size needed per variant
        """
        # Z-scores
        z_alpha = {0.90: 1.645, 0.95: 1.96, 0.99: 2.576}.get(confidence_level, 1.96)
        z_beta = {0.80: 0.84, 0.90: 1.28}.get(power, 0.84)

        p1 = baseline_rate
        p2 = baseline_rate * (1 + min_detectable_effect)

        # Pooled proportion
        p_bar = (p1 + p2) / 2

        numerator = (
            z_alpha * math.sqrt(2 * p_bar * (1 - p_bar))
            + z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))
        ) ** 2
        denominator = (p2 - p1) ** 2

        if denominator == 0:
            return 10000  # Default large number

        return int(math.ceil(numerator / denominator))


# ============================================================
# EXPERIMENT MANAGER
# ============================================================


class ExperimentManager:
    """
    Central manager for experiments and feature flags.

    Handles:
    - Creating and managing experiments
    - Feature flag evaluation
    - Metrics tracking
    - Statistical analysis

    Usage:
        manager = ExperimentManager()

        # Feature flags
        manager.create_flag("dark_mode", enabled_percent=25)
        if manager.is_enabled("dark_mode", user_id="123"):
            enable_dark_mode()

        # Experiments
        manager.create_experiment("checkout_flow", variants=[
            Variant("control", weight=50),
            Variant("simplified", weight=50),
        ])

        variant = manager.get_variant("checkout_flow", user_id="123")
        # ... render variant ...

        manager.track_metric("checkout_flow", user_id="123",
                            metric="conversion", value=1)

        results = manager.analyze("checkout_flow")
    """

    def __init__(self, persistence_path: Optional[str] = None):
        self._flags: Dict[str, FeatureFlag] = {}
        self._experiments: Dict[str, Experiment] = {}
        self._metrics: Dict[str, List[MetricEvent]] = defaultdict(list)
        self._assignments: Dict[str, Dict[str, str]] = defaultdict(
            dict
        )  # user_id -> {exp -> variant}
        self._lock = threading.RLock()
        self._persistence_path = persistence_path

        if persistence_path:
            self._load()

    # --------------------------------------------------------
    # FEATURE FLAGS
    # --------------------------------------------------------

    def create_flag(
        self,
        name: str,
        description: str = "",
        enabled: bool = False,
        enabled_percent: float = 0.0,
        allowlist: Optional[Set[str]] = None,
        tags: Optional[List[str]] = None,
    ) -> FeatureFlag:
        """Create a new feature flag."""
        with self._lock:
            if enabled:
                status = FlagStatus.ENABLED
            elif enabled_percent > 0:
                status = FlagStatus.PERCENTAGE
            else:
                status = FlagStatus.DISABLED

            flag = FeatureFlag(
                name=name,
                description=description,
                status=status,
                enabled_percent=enabled_percent,
                allowlist=allowlist or set(),
                tags=tags or [],
            )

            self._flags[name] = flag
            self._save()

            logger.info(f"Created feature flag: {name}")
            return flag

    def get_flag(self, name: str) -> Optional[FeatureFlag]:
        """Get a feature flag by name."""
        return self._flags.get(name)

    def is_enabled(self, flag_name: str, user_id: str = "") -> bool:
        """Check if a flag is enabled for a user."""
        flag = self._flags.get(flag_name)
        if not flag:
            return False
        return flag.is_enabled(user_id)

    def set_flag_status(
        self,
        name: str,
        status: Optional[FlagStatus] = None,
        enabled_percent: Optional[float] = None,
    ):
        """Update a flag's status."""
        with self._lock:
            flag = self._flags.get(name)
            if not flag:
                raise KeyError(f"Flag not found: {name}")

            if status is not None:
                flag.status = status
            if enabled_percent is not None:
                flag.enabled_percent = enabled_percent
                if enabled_percent > 0 and flag.status == FlagStatus.DISABLED:
                    flag.status = FlagStatus.PERCENTAGE

            flag.updated_at = datetime.now().isoformat()
            self._save()

    def add_to_allowlist(self, flag_name: str, user_ids: List[str]):
        """Add users to a flag's allowlist."""
        with self._lock:
            flag = self._flags.get(flag_name)
            if flag:
                flag.allowlist.update(user_ids)
                flag.updated_at = datetime.now().isoformat()
                self._save()

    def remove_from_allowlist(self, flag_name: str, user_ids: List[str]):
        """Remove users from a flag's allowlist."""
        with self._lock:
            flag = self._flags.get(flag_name)
            if flag:
                flag.allowlist -= set(user_ids)
                flag.updated_at = datetime.now().isoformat()
                self._save()

    def list_flags(self, tag: Optional[str] = None) -> List[FeatureFlag]:
        """List all flags, optionally filtered by tag."""
        flags = list(self._flags.values())
        if tag:
            flags = [f for f in flags if tag in f.tags]
        return flags

    def delete_flag(self, name: str):
        """Delete a feature flag."""
        with self._lock:
            if name in self._flags:
                del self._flags[name]
                self._save()

    # --------------------------------------------------------
    # EXPERIMENTS
    # --------------------------------------------------------

    def create_experiment(
        self,
        name: str,
        variants: Optional[List[Variant]] = None,
        description: str = "",
        hypothesis: str = "",
        primary_metric: str = "",
        secondary_metrics: Optional[List[str]] = None,
        target_percent: float = 100.0,
        min_sample_size: int = 100,
        owner: str = "",
        tags: Optional[List[str]] = None,
    ) -> Experiment:
        """Create a new A/B experiment."""
        with self._lock:
            experiment = Experiment(
                name=name,
                description=description,
                hypothesis=hypothesis,
                variants=variants or [],
                primary_metric=primary_metric,
                secondary_metrics=secondary_metrics or [],
                target_percent=target_percent,
                min_sample_size=min_sample_size,
                owner=owner,
                tags=tags or [],
            )

            self._experiments[name] = experiment
            self._save()

            logger.info(f"Created experiment: {name}")
            return experiment

    def get_experiment(self, name: str) -> Optional[Experiment]:
        """Get an experiment by name."""
        return self._experiments.get(name)

    def start_experiment(self, name: str):
        """Start an experiment (begin collecting data)."""
        with self._lock:
            exp = self._experiments.get(name)
            if not exp:
                raise KeyError(f"Experiment not found: {name}")

            exp.status = ExperimentStatus.RUNNING
            exp.started_at = datetime.now().isoformat()
            self._save()

            logger.info(f"Started experiment: {name}")

    def pause_experiment(self, name: str):
        """Pause an experiment."""
        with self._lock:
            exp = self._experiments.get(name)
            if exp:
                exp.status = ExperimentStatus.PAUSED
                self._save()

    def end_experiment(self, name: str):
        """End an experiment."""
        with self._lock:
            exp = self._experiments.get(name)
            if exp:
                exp.status = ExperimentStatus.COMPLETED
                exp.ended_at = datetime.now().isoformat()
                self._save()

                logger.info(f"Ended experiment: {name}")

    def get_variant(
        self, experiment_name: str, user_id: str, check_targeting: bool = True
    ) -> Optional[Variant]:
        """
        Get the variant assignment for a user.

        Returns None if:
        - Experiment doesn't exist
        - Experiment isn't running
        - User isn't in target segment
        """
        exp = self._experiments.get(experiment_name)
        if not exp:
            return None

        # Check if experiment is running
        if check_targeting and exp.status != ExperimentStatus.RUNNING:
            return None

        # Check if user is in experiment (percentage targeting)
        if check_targeting and exp.target_percent < 100:
            hash_val = int(
                hashlib.md5(f"target:{experiment_name}:{user_id}".encode()).hexdigest(), 16
            )
            if (hash_val % 100) >= exp.target_percent:
                return None

        # Get or assign variant
        with self._lock:
            if user_id in self._assignments and experiment_name in self._assignments[user_id]:
                variant_name = self._assignments[user_id][experiment_name]
                for v in exp.variants:
                    if v.name == variant_name:
                        return v

            # Assign variant
            variant = exp.get_variant(user_id)
            self._assignments[user_id][experiment_name] = variant.name

            return variant

    def list_experiments(
        self, status: Optional[ExperimentStatus] = None, tag: Optional[str] = None
    ) -> List[Experiment]:
        """List experiments with optional filtering."""
        experiments = list(self._experiments.values())

        if status:
            experiments = [e for e in experiments if e.status == status]
        if tag:
            experiments = [e for e in experiments if tag in e.tags]

        return experiments

    # --------------------------------------------------------
    # METRICS
    # --------------------------------------------------------

    def track_metric(
        self,
        experiment_name: str,
        user_id: str,
        metric: str,
        value: float,
        metadata: Optional[Dict] = None,
    ):
        """Track a metric event for an experiment."""
        exp = self._experiments.get(experiment_name)
        if not exp:
            logger.warning(f"Experiment not found: {experiment_name}")
            return

        # Get user's variant
        variant_name = self._assignments.get(user_id, {}).get(experiment_name)
        if not variant_name:
            # Try to get/assign variant
            variant = self.get_variant(experiment_name, user_id, check_targeting=False)
            if variant:
                variant_name = variant.name
            else:
                logger.warning(f"No variant for user {user_id} in {experiment_name}")
                return

        event = MetricEvent(
            experiment_name=experiment_name,
            variant_name=variant_name,
            user_id=user_id,
            metric_name=metric,
            value=value,
            metadata=metadata or {},
        )

        with self._lock:
            self._metrics[experiment_name].append(event)

    def track_conversion(
        self,
        experiment_name: str,
        user_id: str,
        converted: bool = True,
        metadata: Optional[Dict] = None,
    ):
        """Convenience method to track a conversion event."""
        self.track_metric(
            experiment_name,
            user_id,
            metric="conversion",
            value=1.0 if converted else 0.0,
            metadata=metadata,
        )

    def get_metrics(
        self, experiment_name: str, metric_name: Optional[str] = None
    ) -> List[MetricEvent]:
        """Get metric events for an experiment."""
        events = self._metrics.get(experiment_name, [])
        if metric_name:
            events = [e for e in events if e.metric_name == metric_name]
        return events

    # --------------------------------------------------------
    # ANALYSIS
    # --------------------------------------------------------

    def analyze(self, experiment_name: str, metric_name: Optional[str] = None) -> ExperimentResults:
        """
        Analyze experiment results.

        Calculates per-variant statistics and statistical significance.
        """
        exp = self._experiments.get(experiment_name)
        if not exp:
            raise KeyError(f"Experiment not found: {experiment_name}")

        metric_name = metric_name or exp.primary_metric or "conversion"
        events = self._metrics.get(experiment_name, [])

        # Aggregate by variant
        variant_data: Dict[str, VariantMetrics] = {}
        user_variants: Dict[str, str] = {}  # Track unique users per variant

        for event in events:
            if event.metric_name != metric_name:
                continue

            if event.variant_name not in variant_data:
                variant_data[event.variant_name] = VariantMetrics(variant_name=event.variant_name)

            variant_data[event.variant_name].add_value(metric_name, event.value)

            # Track unique users
            if event.user_id not in user_variants:
                user_variants[event.user_id] = event.variant_name
                variant_data[event.variant_name].sample_size += 1

        # Statistical analysis
        control = exp.get_control()
        control_name = (
            control.name if control else (exp.variants[0].name if exp.variants else "control")
        )

        results = ExperimentResults(
            experiment_name=experiment_name,
            status=exp.status.value,
            variant_metrics=variant_data,
            primary_metric=metric_name,
        )

        # Compare treatment variants to control
        if control_name in variant_data and len(variant_data) >= 2:
            control_values = (
                variant_data[control_name].metrics.get(metric_name, {}).get("values", [])
            )

            best_lift = 0.0
            best_variant = ""

            for variant_name, vm in variant_data.items():
                if variant_name == control_name:
                    continue

                treatment_values = vm.metrics.get(metric_name, {}).get("values", [])

                if len(control_values) >= 2 and len(treatment_values) >= 2:
                    test_results = StatisticalAnalyzer.t_test(
                        control_values, treatment_values, exp.confidence_level
                    )

                    control_mean = test_results.get("mean_a", 0)
                    lift = test_results.get("difference", 0) / control_mean if control_mean else 0

                    if test_results.get("significant", False) and lift > best_lift:
                        best_lift = lift
                        best_variant = variant_name
                        results.is_significant = True
                        results.p_value = test_results.get("p_value", 1.0)

            if best_variant:
                results.winner = best_variant
                results.lift = best_lift

            # Confidence interval for control
            if control_values:
                results.confidence_interval = StatisticalAnalyzer.confidence_interval(
                    control_values, exp.confidence_level
                )

        return results

    def get_sample_size_recommendation(
        self, experiment_name: str, baseline_rate: float = 0.10, min_detectable_effect: float = 0.10
    ) -> Dict[str, Any]:
        """Get sample size recommendation for an experiment."""
        exp = self._experiments.get(experiment_name)
        if not exp:
            raise KeyError(f"Experiment not found: {experiment_name}")

        sample_per_variant = StatisticalAnalyzer.sample_size_needed(
            baseline_rate, min_detectable_effect, exp.confidence_level
        )

        total_needed = sample_per_variant * len(exp.variants)

        # Check current progress
        events = self._metrics.get(experiment_name, [])
        unique_users = len(set(e.user_id for e in events))

        return {
            "sample_per_variant": sample_per_variant,
            "total_needed": total_needed,
            "current_users": unique_users,
            "percent_complete": min(100, (unique_users / total_needed) * 100)
            if total_needed
            else 0,
            "variants": len(exp.variants),
        }

    # --------------------------------------------------------
    # PERSISTENCE
    # --------------------------------------------------------

    def _save(self):
        """Save state to file."""
        if not self._persistence_path:
            return

        try:
            data = {
                "flags": {k: v.to_dict() for k, v in self._flags.items()},
                "experiments": {k: v.to_dict() for k, v in self._experiments.items()},
                "assignments": dict(self._assignments),
            }

            with open(self._persistence_path, "w") as f:
                json.dump(data, f, indent=2)
        except Exception as e:
            logger.error(f"Failed to save experiment state: {e}")

    def _load(self):
        """Load state from file."""
        if not self._persistence_path:
            return

        try:
            with open(self._persistence_path, "r") as f:
                data = json.load(f)

            self._flags = {k: FeatureFlag.from_dict(v) for k, v in data.get("flags", {}).items()}
            self._experiments = {
                k: Experiment.from_dict(v) for k, v in data.get("experiments", {}).items()
            }
            self._assignments = defaultdict(dict, data.get("assignments", {}))

        except FileNotFoundError as e:
            logger.debug("File not found (suppressed): %s", e)
        except Exception as e:
            logger.error(f"Failed to load experiment state: {e}")

    def export_results(self, experiment_name: str, filepath: str):
        """Export experiment results to JSON."""
        results = self.analyze(experiment_name)

        with open(filepath, "w") as f:
            json.dump(results.to_dict(), f, indent=2)

    def get_summary(self) -> Dict[str, Any]:
        """Get a summary of all experiments and flags."""
        return {
            "flags": {
                "total": len(self._flags),
                "enabled": len([f for f in self._flags.values() if f.status == FlagStatus.ENABLED]),
                "percentage": len(
                    [f for f in self._flags.values() if f.status == FlagStatus.PERCENTAGE]
                ),
            },
            "experiments": {
                "total": len(self._experiments),
                "running": len(
                    [e for e in self._experiments.values() if e.status == ExperimentStatus.RUNNING]
                ),
                "completed": len(
                    [
                        e
                        for e in self._experiments.values()
                        if e.status == ExperimentStatus.COMPLETED
                    ]
                ),
            },
            "metrics_events": sum(len(events) for events in self._metrics.values()),
        }


# ============================================================
# GLOBAL INSTANCE
# ============================================================

_experiment_manager: Optional[ExperimentManager] = None


def get_experiment_manager() -> ExperimentManager:
    """Get the global experiment manager instance."""
    global _experiment_manager
    if _experiment_manager is None:
        _experiment_manager = ExperimentManager()
    return _experiment_manager


def set_experiment_manager(manager: ExperimentManager):
    """Set the global experiment manager instance."""
    global _experiment_manager
    _experiment_manager = manager


# ============================================================
# CONVENIENCE FUNCTIONS
# ============================================================


def is_enabled(flag_name: str, user_id: str = "") -> bool:
    """Check if a feature flag is enabled."""
    return get_experiment_manager().is_enabled(flag_name, user_id)


def get_variant(experiment_name: str, user_id: str) -> Optional[Variant]:
    """Get the variant for a user in an experiment."""
    return get_experiment_manager().get_variant(experiment_name, user_id)


def track_metric(experiment_name: str, user_id: str, metric: str, value: float):
    """Track a metric for an experiment."""
    get_experiment_manager().track_metric(experiment_name, user_id, metric, value)


def track_conversion(experiment_name: str, user_id: str, converted: bool = True):
    """Track a conversion for an experiment."""
    get_experiment_manager().track_conversion(experiment_name, user_id, converted)
