"""Training profile estimator — memory, speed, and recommendations."""

import math

# GPU memory lookup table (name → GB VRAM)
GPU_MEMORY: dict[str, int] = {
    "rtx3060": 12,
    "rtx3070": 8,
    "rtx3070ti": 8,
    "rtx3080": 10,
    "rtx3080ti": 12,
    "rtx3090": 24,
    "rtx4060": 8,
    "rtx4060ti": 16,
    "rtx4070": 12,
    "rtx4070ti": 12,
    "rtx4080": 16,
    "rtx4090": 24,
    "rtx5090": 32,
    "a10": 24,
    "a30": 24,
    "a40": 48,
    "a100": 80,
    "a100_40gb": 40,
    "h100": 80,
    "h200": 141,
    "l4": 24,
    "l40": 48,
    "l40s": 48,
    "t4": 16,
    "v100": 32,
}

# Known model architectures: model_size_b → (hidden_size, num_layers, intermediate_size)
_KNOWN_ARCHS: dict[float, tuple[int, int, int]] = {
    0.5: (896, 24, 4864),
    1.0: (2048, 22, 5632),
    1.5: (2048, 28, 5632),
    3.0: (3072, 28, 8192),
    7.0: (4096, 32, 11008),
    8.0: (4096, 32, 14336),
    13.0: (5120, 40, 13824),
    14.0: (5120, 40, 14336),
    32.0: (5120, 64, 27648),
    34.0: (8192, 48, 22016),
    70.0: (8192, 80, 28672),
}


def get_model_arch(model_name: str, model_params_b: float) -> dict:
    """Get model architecture estimates (hidden_size, num_layers).

    Uses known architectures when possible, otherwise estimates from param count.
    """
    # Find closest known architecture
    closest_size = min(_KNOWN_ARCHS.keys(), key=lambda sz: abs(sz - model_params_b))

    # Use closest if within 20% range
    if abs(closest_size - model_params_b) / max(model_params_b, 0.1) <= 0.2:
        hidden, layers, intermediate = _KNOWN_ARCHS[closest_size]
        return {
            "hidden_size": hidden,
            "num_layers": layers,
            "intermediate_size": intermediate,
        }

    # Estimate from param count using scaling laws
    # params ≈ 12 * num_layers * hidden_size^2 (rough transformer scaling)
    hidden_size = int(math.sqrt(model_params_b * 1e9 / (12 * 32)))
    # Round to nearest 128
    hidden_size = max(128, (hidden_size // 128) * 128)
    num_layers = int(model_params_b * 1e9 / (12 * hidden_size * hidden_size))
    num_layers = max(1, num_layers)
    intermediate_size = hidden_size * 3

    return {
        "hidden_size": hidden_size,
        "num_layers": num_layers,
        "intermediate_size": intermediate_size,
    }


def estimate_model_memory(model_params_b: float, quantization: str) -> float:
    """Estimate model weight memory in GB.

    Args:
        model_params_b: Model size in billions of parameters.
        quantization: '4bit', '8bit', or 'none' (FP16).

    Returns:
        Estimated memory in GB.
    """
    bytes_per_param = {"4bit": 0.5, "8bit": 1.0, "none": 2.0}
    bpp = bytes_per_param.get(quantization, 2.0)
    return model_params_b * bpp


def estimate_trainable_params(
    model_params_b: float, lora_r: int, hidden_size: int
) -> int:
    """Estimate number of trainable LoRA parameters.

    LoRA adds r × hidden_size × 2 params per target module.
    Typical target modules: q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj
    """
    num_target_modules = 7  # typical for LLaMA-family
    arch = get_model_arch("", model_params_b)
    num_layers = arch["num_layers"]

    # Each module: r * hidden_size (A) + hidden_size * r (B) = 2 * r * hidden_size
    params_per_layer = num_target_modules * 2 * lora_r * hidden_size
    return params_per_layer * num_layers


def estimate_lora_memory(
    model_params_b: float, lora_r: int, lora_alpha: int
) -> float:
    """Estimate LoRA adapter memory in GB (FP16 for trainable params)."""
    arch = get_model_arch("", model_params_b)
    trainable = estimate_trainable_params(model_params_b, lora_r, arch["hidden_size"])
    # FP16 = 2 bytes per param
    return trainable * 2 / (1024**3)


def estimate_optimizer_memory(trainable_params: int, optimizer: str) -> float:
    """Estimate optimizer state memory in GB.

    Adam: 2 states per param (mean + variance) = 2 × param_bytes
    SGD: 1 state (momentum) = 1 × param_bytes
    8bit Adam: ~0.5 × normal Adam
    """
    bytes_per_param = 2  # FP16 trainable params

    if "8bit" in optimizer or "bnb" in optimizer:
        # 8-bit optimizer states
        state_multiplier = 1.0  # reduced from 2.0
    elif "sgd" in optimizer.lower():
        state_multiplier = 1.0  # only momentum
    else:
        # AdamW and similar: mean + variance
        state_multiplier = 2.0

    total_bytes = trainable_params * bytes_per_param * state_multiplier
    return total_bytes / (1024**3)


def estimate_activation_memory(
    batch_size: int,
    seq_len: int,
    hidden_size: int,
    num_layers: int,
    gradient_checkpointing: bool = False,
) -> float:
    """Estimate activation memory in GB.

    Activations scale with batch_size × seq_len × hidden_size × num_layers.
    Gradient checkpointing reduces this by ~sqrt(num_layers).
    """
    # ~2 bytes per activation value, ~4 activations per layer per position
    activation_bytes = batch_size * seq_len * hidden_size * 4 * 2

    if gradient_checkpointing:
        # Only store activations at checkpoints (sqrt reduction)
        effective_layers = max(1, int(math.sqrt(num_layers)))
    else:
        effective_layers = num_layers

    total_bytes = activation_bytes * effective_layers
    return total_bytes / (1024**3)


def estimate_total(
    model_name: str,
    model_params_b: float,
    quantization: str,
    lora_r: int,
    lora_alpha: int,
    batch_size: int,
    seq_len: int,
    optimizer: str,
    gradient_checkpointing: bool,
) -> dict:
    """Compute full memory profile.

    Returns dict with breakdown and total.
    """
    arch = get_model_arch(model_name, model_params_b)

    model_mem = estimate_model_memory(model_params_b, quantization)
    lora_mem = estimate_lora_memory(model_params_b, lora_r, lora_alpha)
    trainable = estimate_trainable_params(
        model_params_b, lora_r, arch["hidden_size"]
    )
    opt_mem = estimate_optimizer_memory(trainable, optimizer)
    act_mem = estimate_activation_memory(
        batch_size, seq_len, arch["hidden_size"], arch["num_layers"],
        gradient_checkpointing=gradient_checkpointing,
    )

    overhead = 1.5  # CUDA context + fragmentation
    total = model_mem + lora_mem + opt_mem + act_mem + overhead

    return {
        "model_name": model_name,
        "model_params_b": model_params_b,
        "trainable_params": trainable,
        "quantization": quantization,
        "model_memory_gb": round(model_mem, 2),
        "lora_memory_gb": round(lora_mem, 2),
        "optimizer_memory_gb": round(opt_mem, 2),
        "activation_memory_gb": round(act_mem, 2),
        "overhead_gb": overhead,
        "total_memory_gb": round(total, 2),
        "batch_size": batch_size,
        "seq_len": seq_len,
        "gradient_checkpointing": gradient_checkpointing,
        "hidden_size": arch["hidden_size"],
        "num_layers": arch["num_layers"],
    }


def estimate_speed(
    model_params_b: float, quantization: str, batch_size: int
) -> float:
    """Estimate training tokens/sec (rough lookup-based).

    Based on typical A100 throughput for different model sizes.
    """
    # Base tokens/sec on A100 for different sizes (4bit, batch=4)
    base_speed: dict[float, float] = {
        1.0: 5000,
        3.0: 2500,
        7.0: 1200,
        8.0: 1100,
        13.0: 600,
        14.0: 550,
        32.0: 250,
        34.0: 230,
        70.0: 100,
    }

    # Find closest size
    closest_key = min(
        base_speed.keys(),
        key=lambda size: abs(size - model_params_b),
    )
    speed = base_speed[closest_key]

    # Adjust for quantization
    quant_factor = {"4bit": 1.0, "8bit": 0.8, "none": 0.5}
    speed *= quant_factor.get(quantization, 0.5)

    # Adjust for batch size (relative to base batch=4)
    speed *= min(batch_size / 4, 2.0)  # diminishing returns above 8

    return speed


def estimate_training_time(
    dataset_size: int, epochs: int, samples_per_sec: float
) -> float:
    """Estimate total training time in minutes.

    Returns float('inf') if samples_per_sec is 0.
    """
    if samples_per_sec <= 0:
        return float("inf")

    total_samples = dataset_size * epochs
    total_seconds = total_samples / samples_per_sec
    return total_seconds / 60


def recommend_batch_size(total_memory_gb: float, gpu_memory_gb: float) -> int:
    """Recommend batch size based on available GPU memory.

    Leaves headroom for memory spikes during training.
    """
    available = gpu_memory_gb - total_memory_gb
    if available <= 0:
        return 1

    # Each additional batch sample needs roughly (activation memory / current batch)
    # Simplified: available memory / ~1.5 GB per additional sample for 7B-class models
    extra_samples = int(available / 1.5)
    batch_size = max(1, 1 + extra_samples)

    # Clamp to power of 2
    if batch_size > 1:
        batch_size = 2 ** int(math.log2(batch_size))

    return min(batch_size, 64)


def recommend_gpu(total_memory_gb: float) -> list[str]:
    """Recommend GPUs that can fit the estimated memory.

    Returns list of GPU names that have enough VRAM.
    """
    compatible = []
    for name, vram in sorted(GPU_MEMORY.items(), key=lambda item: item[1]):
        if vram >= total_memory_gb * 1.1:  # 10% headroom
            compatible.append(f"{name.upper()} ({vram} GB)")

    if not compatible:
        compatible.append(
            f"No single GPU fits {total_memory_gb:.1f} GB -- use multi-GPU (DeepSpeed/FSDP)"
        )

    return compatible
