"""
ConvMLP Layers
==============
Vedic-inspired optimized layer implementations.
"""

import numpy as np
from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin
from sklearn.utils.validation import check_X_y, check_array, check_is_fitted
from sklearn.utils.multiclass import unique_labels
from typing import Tuple, Optional, Union, List
from functools import lru_cache
import warnings

from .utils import im2col, col2im


# ============================================================================
# BASE LAYER CLASS
# ============================================================================

class Layer:
    """Base class for all neural network layers."""
    
    def __init__(self):
        self.trainable = True
        self.built = False
    
    def forward(self, x, training=False):
        raise NotImplementedError
    
    def backward(self, grad_output):
        raise NotImplementedError

    def build(self, input_shape):
        """Default no-op build for layers without trainable parameters."""
        self.input_shape = input_shape
        self.output_shape = input_shape
        self.built = True
        return input_shape
    
    def get_params(self):
        return {}
    
    def set_params(self, **params):
        for key, value in params.items():
            if hasattr(self, key):
                setattr(self, key, value)
        return self


# ============================================================================
# CONV2D LAYER - The Core Vedic-Optimized Implementation
# ============================================================================

class Conv2D(Layer):
    """
    2D Convolutional Layer with Vedic-optimized computation.
    
    Implements multiple computation strategies:
    - im2col (Vedic block multiplication)
    - FFT convolution (frequency domain)
    - Winograd minimal filtering
    - Nikhilam quantized inference
    
    Parameters
    ----------
    filters : int
        Number of convolution filters (output channels).
    kernel_size : tuple or int
        Size of the convolution kernel.
    strides : int or tuple, default=1
        Stride length.
    padding : str or int, default='same'
        Padding mode: 'same', 'valid', or integer.
    activation : str, default='relu'
        Activation function.
    use_bias : bool, default=True
        Whether to use bias term.
    method : str, default='im2col'
        Computation method: 'im2col', 'fft', 'winograd', 'direct'.
    kernel_initializer : str, default='he_normal'
        Weight initialization method.
    
    Example
    -------
    >>> layer = Conv2D(filters=32, kernel_size=(3, 3), padding='same')
    >>> output = layer.forward(input_array)
    """
    
    def __init__(
        self,
        filters: int,
        kernel_size: Union[int, Tuple[int, int]],
        strides: int = 1,
        padding: Union[str, int] = 'same',
        activation: str = 'relu',
        use_bias: bool = True,
        method: str = 'im2col',
        kernel_initializer: str = 'he_normal'
    ):
        super().__init__()
        self.filters = filters
        self.kernel_size = (kernel_size, kernel_size) if isinstance(kernel_size, int) else kernel_size
        self.strides = (strides, strides) if isinstance(strides, int) else strides
        self.padding = padding
        self.activation = activation
        self.use_bias = use_bias
        self.method = method
        self.kernel_initializer = kernel_initializer
        
        # Parameters (set during build)
        self.kernel = None
        self.bias = None
        self.input_shape = None
        
        # Cache for backward pass
        self._cache = {}
    
    def build(self, input_shape: Tuple[int, ...]):
        """Initialize weights based on input shape."""
        self.input_shape = input_shape
        
        # He initialization for ReLU networks
        if self.kernel_initializer == 'he_normal':
            std = np.sqrt(2.0 / (input_shape[-1] * np.prod(self.kernel_size)))
        elif self.kernel_initializer == 'glorot_uniform':
            std = np.sqrt(2.0 / (input_shape[-1] + self.filters))
        else:
            std = 0.02
        
        self.kernel = np.random.randn(
            self.filters, 
            input_shape[-3],  # channels
            *self.kernel_size
        ).astype(np.float32) * std
        
        if self.use_bias:
            self.bias = np.zeros(self.filters, dtype=np.float32)
        
        self.built = True
        
        # Calculate output shape
        self._output_shape = self._compute_output_shape(input_shape)
        return self._output_shape
    
    def _compute_output_shape(self, input_shape):
        """Calculate output spatial dimensions."""
        h, w = input_shape[-2:]
        kh, kw = self.kernel_size
        sh, sw = self.strides
        
        if isinstance(self.padding, str):
            if self.padding == 'same':
                out_h = int(np.ceil(h / sh))
                out_w = int(np.ceil(w / sw))
            else:  # 'valid'
                out_h = int(np.ceil((h - kh + 1) / sh))
                out_w = int(np.ceil((w - kw + 1) / sw))
        else:
            # Custom padding
            out_h = int(np.ceil((h + 2 * self.padding - kh + 1) / sh))
            out_w = int(np.ceil((w + 2 * self.padding - kw + 1) / sw))
        
        return (input_shape[0], self.filters, out_h, out_w) if len(input_shape) == 4 \
               else (self.filters, out_h, out_w)
    
    def _pad_input(self, x: np.ndarray) -> np.ndarray:
        """Apply padding to input."""
        if self.padding == 'valid':
            return x
        
        if self.padding == 'same':
            h, w = x.shape[-2:]
            sh, sw = self.strides
            pad_h = max(0, (sh - 1) * h - h + sh)
            pad_w = max(0, (sw - 1) * w - w + sw)
        else:
            pad_h = pad_w = self.padding
        
        pad_top = pad_h // 2
        pad_left = pad_w // 2
        
        if len(x.shape) == 4:
            return np.pad(x, ((0, 0), (0, 0), (pad_top, pad_h - pad_top), 
                             (pad_left, pad_w - pad_left)), mode='constant')
        else:
            return np.pad(x, ((0, 0), (0, 0), (pad_top, pad_h - pad_top), 
                             (pad_left, pad_w - pad_left)), mode='constant')
    
    def _get_padding(self):
        """Calculate padding values."""
        if self.padding == 'valid':
            return 0, 0
        elif self.padding == 'same':
            # For stride 1, same padding is (k-1)/2
            return self.kernel_size[0] // 2, self.kernel_size[1] // 2
        else:
            return self.padding, self.padding
    
    # ========================================================================
    # VEDIC-OPTIMIZED FORWARD METHODS
    # ========================================================================
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        """
        Forward pass using the configured method.
        
        Parameters
        ----------
        x : np.ndarray
            Input tensor of shape (batch, channels, height, width)
        training : bool
            Whether in training mode (affects dropout, etc.)
        
        Returns
        -------
        output : np.ndarray
            Convolved output
        """
        if not self.built:
            self.build(x.shape)
        
        self._input = x
        
        # Apply padding
        x_padded = self._pad_input(x)
        
        # Select computation method
        if self.method == 'im2col':
            output = self._forward_im2col(x_padded)
        elif self.method == 'fft':
            output = self._forward_fft(x_padded)
        elif self.method == 'winograd':
            output = self._forward_winograd(x_padded)
        elif self.method == 'direct':
            output = self._forward_direct(x_padded)
        else:
            output = self._forward_im2col(x_padded)
        
        # Add bias
        if self.use_bias:
            output += self.bias.reshape(1, -1, 1, 1)
        
        # Apply activation
        output = self._apply_activation(output)
        
        return output
    
    def _forward_direct(self, x_padded: np.ndarray) -> np.ndarray:
        """Direct convolution (naive but correct)."""
        batch, channels, h, w = x_padded.shape
        kh, kw = self.kernel_size
        sh, sw = self.strides
        
        out_h = (h - kh) // sh + 1
        out_w = (w - kw) // sw + 1
        
        output = np.zeros((batch, self.filters, out_h, out_w), dtype=np.float32)
        
        for b in range(batch):
            for f in range(self.filters):
                for i in range(0, h - kh + 1, sh):
                    for j in range(0, w - kw + 1, sw):
                        patch = x_padded[b, :, i:i+kh, j:j+kw]
                        output[b, f, i//sh, j//sw] = np.sum(patch * self.kernel[f])
        
        return output
    
    def _forward_im2col(self, x_padded: np.ndarray) -> np.ndarray:
        """
        Vedic Block Multiplication (im2col) Method.
        
        Inspired by Urdhva-Tiryagbyham (vertically and crosswise).
        Unfold image patches into columns for efficient matrix multiplication.
        
        Time Complexity: O(n²) matrix mult instead of O(n³) nested loops
        """
        batch, channels, h, w = x_padded.shape
        kh, kw = self.kernel_size
        sh, sw = self.strides
        
        out_h = (h - kh) // sh + 1
        out_w = (w - kw) // sw + 1
        
        # im2col: Extract all patches and stack as columns
        # Shape: (batch, channels * kh * kw, out_h * out_w)
        patches = im2col(x_padded, self.kernel_size, self.strides)
        
        # Reshape kernel: (filters, channels, kh, kw) -> (filters, channels * kh * kw)
        kernel_col = self.kernel.reshape(self.filters, -1)
        
        # Matrix multiply: (batch, out_h*out_w, filters)
        output = np.matmul(patches.transpose(0, 2, 1), kernel_col.T)
        
        # Reshape to output format: (batch, filters, out_h, out_w)
        output = output.transpose(0, 2, 1).reshape(batch, self.filters, out_h, out_w)
        
        return output
    
    def _forward_fft(self, x_padded: np.ndarray) -> np.ndarray:
        """
        FFT Convolution Method.
        
        Based on convolution theorem: conv in spatial = multiply in frequency.
        Optimal for large kernels (k >= 7).
        
        Time Complexity: O(n log n) instead of O(n²)
        """
        batch, channels, h, w = x_padded.shape
        kh, kw = self.kernel_size
        sh, sw = self.strides
        
        # Pad to next power of 2 for efficient FFT
        fft_h = 2 ** int(np.ceil(np.log2(h + kh - 1)))
        fft_w = 2 ** int(np.ceil(np.log2(w + kw - 1)))
        
        # FFT of input patches
        X_fft = np.fft.rfft2(x_padded, s=(fft_h, fft_w))
        
        output = np.zeros((batch, self.filters, h - kh + 1, w - kw + 1), dtype=np.float32)
        
        for f in range(self.filters):
            # Create frequency-domain kernel
            kernel_padded = np.zeros((fft_h, fft_w), dtype=np.float32)
            kernel_padded[:kh, :kw] = self.kernel[f]
            K_fft = np.fft.rfft2(kernel_padded)
            
            # Multiply in frequency domain
            out_fft = X_fft * K_fft
            
            # Inverse FFT and extract valid region
            output[:, f] = np.fft.irfft2(out_fft, s=(fft_h, fft_w))[:, :h-kh+1, :w-kw+1]
        
        return output[:, :, ::sh, ::sw]
    
    def _forward_winograd(self, x_padded: np.ndarray) -> np.ndarray:
        """
        Winograd Minimal Filtering Method.
        
        Reduces multiplication count for small kernels (3x3).
        Uses precomputed transformation matrices.
        
        For 3x3 kernel on 4x4 output: 16 mults -> 4 multiplies.
        """
        if self.kernel_size != (3, 3):
            warnings.warn("Winograd optimized for 3x3 kernels. Falling back to im2col.")
            return self._forward_im2col(x_padded)
        
        # Winograd F(2x2, 3x3) transformation matrices
        G = np.array([
            [1, 0, 0],
            [0.5, 0.5, 0.5],
            [0.5, -0.5, 0.5],
            [0, 0, 1]
        ], dtype=np.float32)
        
        B = np.array([
            [1, 0, -1, 0],
            [0, 1, 1, 0],
            [0, -1, 1, 0],
            [1, 1, 0, 0],
            [0, 1, 0, -1]
        ], dtype=np.float32)
        
        A = np.array([
            [1, 0],
            [1, 1],
            [1, -1],
            [0, -1]
        ], dtype=np.float32)
        
        batch, channels, h, w = x_padded.shape
        sh, sw = self.strides
        out_h = (h - 3) // sh + 1
        out_w = (w - 3) // sw + 1
        
        output = np.zeros((batch, self.filters, out_h, out_w), dtype=np.float32)
        
        # Transform kernel: (C, 3, 3) -> (C, 4, 4)
        G_kernel = np.einsum('ij,jk,lk->il', G, self.kernel[0], G)
        
        # Process tiles
        tile_h = 2 + 3 - 1  # output + kernel - 1
        tile_w = 2 + 3 - 1
        
        for b in range(batch):
            for f in range(self.filters):
                for i in range(0, h - 3 + 1, sh * 2):
                    for j in range(0, w - 3 + 1, sw * 2):
                        # Extract 5x5 tile (2x2 output + 3x3 kernel)
                        tile = x_padded[b, :, i:i+tile_h, j:j+tile_w]
                        
                        # Transform tile
                        B_tile = np.einsum('ij,bchw,kw->bcijk', B, tile.reshape(1, channels, tile_h, tile_w), B)
                        
                        # Element-wise multiply (the expensive part)
                        m = B_tile * G_kernel[None, :, :, :]
                        
                        # Inverse transform
                        result = np.einsum('ij,bcijk,kj->bckl', A, m, A)
                        
                        # Place in output
                        out_i = i // sh
                        out_j = j // sw
                        output[b, f, out_i:out_i+2, out_j:out_j+2] = result[0, 0]
        
        return output
    
    def _apply_activation(self, x: np.ndarray) -> np.ndarray:
        """Apply activation function."""
        if self.activation == 'relu':
            return np.maximum(0, x)
        elif self.activation == 'sigmoid':
            return 1 / (1 + np.exp(-np.clip(x, -500, 500)))
        elif self.activation == 'tanh':
            return np.tanh(x)
        elif self.activation == 'softmax':
            exp_x = np.exp(x - np.max(x, axis=1, keepdims=True))
            return exp_x / np.sum(exp_x, axis=1, keepdims=True)
        elif self.activation is None or self.activation == 'linear':
            return x
        else:
            return x
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        """
        Backward pass for gradient computation.
        
        Uses the same im2col method for efficiency.
        """
        # Apply activation gradient
        grad_output = self._activation_grad(grad_output)
        
        # Remove bias gradient
        if self.use_bias:
            grad_bias = np.sum(grad_output, axis=(0, 2, 3))
        
        # im2col for input gradient
        x_padded = self._pad_input(self._input)
        batch, channels, h, w = x_padded.shape
        kh, kw = self.kernel_size
        sh, sw = self.strides
        out_h, out_w = grad_output.shape[-2:]
        
        # Reshape grad_output for im2col computation
        grad_out_reshaped = grad_output.transpose(0, 1, 3, 2).reshape(batch, self.filters, -1)
        
        # Gradient w.r.t. kernel
        patches = im2col(x_padded, self.kernel_size, self.strides)
        grad_kernel = np.matmul(grad_out_reshaped, patches.transpose(0, 2, 1))
        grad_kernel = grad_kernel.transpose(0, 2, 1).reshape(self.filters, channels, kh, kw)
        
        # Gradient w.r.t. input
        grad_kernel_col = self.kernel.reshape(self.filters, -1)
        grad_patches = np.matmul(grad_out_reshaped, grad_kernel_col.T)
        grad_input_padded = col2im(grad_patches, (batch, channels, h, w), 
                                    self.kernel_size, self.strides)
        
        # Remove padding
        pad_h, pad_w = self._get_padding()
        if pad_h > 0 or pad_w > 0:
            if len(grad_input_padded.shape) == 4:
                grad_input = grad_input_padded[:, :, pad_h:-pad_h or None, pad_w:-pad_w or None]
            else:
                grad_input = grad_input_padded[:, pad_h:-pad_h or None, pad_w:-pad_w or None]
        else:
            grad_input = grad_input_padded
        
        return grad_input
    
    def _activation_grad(self, grad_output: np.ndarray) -> np.ndarray:
        """Compute gradient through activation function."""
        if self.activation == 'relu':
            return grad_output * (self._input > 0)
        elif self.activation in ['sigmoid', 'tanh']:
            # Approximate - exact would need forward cache
            return grad_output * 0.25
        else:
            return grad_output
    
    def get_params(self) -> dict:
        return {
            'filters': self.filters,
            'kernel_size': self.kernel_size,
            'strides': self.strides,
            'padding': self.padding,
            'activation': self.activation,
            'use_bias': self.use_bias,
            'method': self.method,
            'kernel': self.kernel,
            'bias': self.bias,
        }
    
    def set_weights(self, kernel: np.ndarray, bias: Optional[np.ndarray] = None):
        """Set layer weights."""
        self.kernel = kernel.astype(np.float32)
        if bias is not None and self.use_bias:
            self.bias = bias.astype(np.float32)
        self.built = True


# ============================================================================
# POOLING LAYERS
# ============================================================================

class MaxPool2D(Layer):
    """Max pooling layer with Vedic-optimized computation."""
    
    def __init__(self, pool_size: int = 2, strides: Optional[int] = None, padding: int = 0):
        super().__init__()
        self.pool_size = (pool_size, pool_size) if isinstance(pool_size, int) else pool_size
        self.strides = strides or self.pool_size
        self.strides = (self.strides, self.strides) if isinstance(self.strides, int) else self.strides
        self.padding = padding

    def build(self, input_shape: Tuple[int, ...]):
        self.input_shape = input_shape
        self.output_shape = self._compute_output_shape(input_shape)
        self.built = True
        return self.output_shape

    def _compute_output_shape(self, input_shape: Tuple[int, ...]):
        h, w = input_shape[-2:]
        ph, pw = self.pool_size
        sh, sw = self.strides
        out_h = (h - ph + 2 * self.padding) // sh + 1
        out_w = (w - pw + 2 * self.padding) // sw + 1

        if len(input_shape) == 4:
            return (input_shape[0], input_shape[1], out_h, out_w)
        return (input_shape[0], out_h, out_w)
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        self._input = x
        batch, channels, h, w = x.shape
        ph, pw = self.pool_size
        sh, sw = self.strides
        
        out_h = (h - ph + 2 * self.padding) // sh + 1
        out_w = (w - pw + 2 * self.padding) // sw + 1
        
        if self.padding > 0:
            x = np.pad(x, ((0, 0), (0, 0), (self.padding, self.padding), 
                          (self.padding, self.padding)), mode='constant')
        
        output = np.zeros((batch, channels, out_h, out_w), dtype=np.float32)
        
        for i in range(out_h):
            for j in range(out_w):
                h_start, w_start = i * sh, j * sw
                patch = x[:, :, h_start:h_start+ph, w_start:w_start+pw]
                output[:, :, i, j] = np.max(patch, axis=(2, 3))
        
        return output
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        batch, channels, out_h, out_w = grad_output.shape
        ph, pw = self.pool_size
        sh, sw = self.strides
        
        grad_input = np.zeros_like(self._input)
        
        for i in range(out_h):
            for j in range(out_w):
                h_start, w_start = i * sh, j * sw
                
                # Find which input contributed to max
                patch = self._input[:, :, h_start:h_start+ph, w_start:w_start+pw]
                max_mask = (patch == np.max(patch, axis=(2, 3), keepdims=True))
                
                # Add gradient to max positions
                for b in range(batch):
                    for c in range(channels):
                        grad_input[b, c, h_start:h_start+ph, w_start:w_start+pw] += \
                            max_mask[b, c] * grad_output[b, c, i, j]
        
        return grad_input


class AvgPool2D(Layer):
    """Average pooling layer."""
    
    def __init__(self, pool_size: int = 2, strides: Optional[int] = None, padding: int = 0):
        super().__init__()
        self.pool_size = (pool_size, pool_size) if isinstance(pool_size, int) else pool_size
        self.strides = strides or self.pool_size
        self.strides = (self.strides, self.strides) if isinstance(self.strides, int) else self.strides
        self.padding = padding

    def build(self, input_shape: Tuple[int, ...]):
        self.input_shape = input_shape
        self.output_shape = self._compute_output_shape(input_shape)
        self.built = True
        return self.output_shape

    def _compute_output_shape(self, input_shape: Tuple[int, ...]):
        h, w = input_shape[-2:]
        ph, pw = self.pool_size
        sh, sw = self.strides
        out_h = (h - ph + 2 * self.padding) // sh + 1
        out_w = (w - pw + 2 * self.padding) // sw + 1

        if len(input_shape) == 4:
            return (input_shape[0], input_shape[1], out_h, out_w)
        return (input_shape[0], out_h, out_w)
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        self._input = x
        batch, channels, h, w = x.shape
        ph, pw = self.pool_size
        sh, sw = self.strides
        
        out_h = (h - ph + 2 * self.padding) // sh + 1
        out_w = (w - pw + 2 * self.padding) // sw + 1
        
        if self.padding > 0:
            x = np.pad(x, ((0, 0), (0, 0), (self.padding, self.padding), 
                          (self.padding, self.padding)), mode='constant')
        
        output = np.zeros((batch, channels, out_h, out_w), dtype=np.float32)
        
        for i in range(out_h):
            for j in range(out_w):
                h_start, w_start = i * sh, j * sw
                patch = x[:, :, h_start:h_start+ph, w_start:w_start+pw]
                output[:, :, i, j] = np.mean(patch, axis=(2, 3))
        
        return output
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        batch, channels, out_h, out_w = grad_output.shape
        ph, pw = self.pool_size
        sh, sw = self.strides
        
        grad_input = np.zeros_like(self._input)
        pool_size = ph * pw
        
        for i in range(out_h):
            for j in range(out_w):
                h_start, w_start = i * sh, j * sw
                grad_input[:, :, h_start:h_start+ph, w_start:w_start+pw] = \
                    grad_output[:, :, i:i+1, j:j+1] / pool_size
        
        return grad_input


# ============================================================================
# UTILITY LAYERS
# ============================================================================

class Flatten(Layer):
    """Flatten layer to convert 4D to 2D for MLP."""
    
    def __init__(self):
        super().__init__()
        self.input_shape = None
        self.output_shape = None
    
    def build(self, input_shape: Tuple[int, ...]):
        self.input_shape = input_shape
        if len(input_shape) == 4:
            self.output_shape = (input_shape[0], np.prod(input_shape[1:]))
        else:
            self.output_shape = (np.prod(input_shape),)
        self.built = True
        return self.output_shape
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        self._input_shape = x.shape
        return x.reshape(x.shape[0], -1)
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        return grad_output.reshape(self._input_shape)


class Dropout2D(Layer):
    """2D Dropout layer."""
    
    def __init__(self, rate: float = 0.5):
        super().__init__()
        self.rate = rate
        self.mask = None
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        if training:
            self.mask = np.random.binomial(1, 1 - self.rate, x.shape).astype(np.float32)
            return x * self.mask / (1 - self.rate)
        return x
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        return grad_output * self.mask / (1 - self.rate)


class BatchNorm2D(Layer):
    """Batch normalization for 2D inputs."""
    
    def __init__(self, momentum: float = 0.9, epsilon: float = 1e-5):
        super().__init__()
        self.momentum = momentum
        self.epsilon = epsilon
        self.gamma = None
        self.beta = None
        self.running_mean = None
        self.running_var = None
    
    def build(self, input_shape: Tuple[int, ...]):
        channels = input_shape[-3]
        self.gamma = np.ones(channels, dtype=np.float32)
        self.beta = np.zeros(channels, dtype=np.float32)
        self.running_mean = np.zeros(channels, dtype=np.float32)
        self.running_var = np.ones(channels, dtype=np.float32)
        self.built = True
        return input_shape
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        if training:
            mean = np.mean(x, axis=(0, 2, 3), keepdims=True)
            var = np.var(x, axis=(0, 2, 3), keepdims=True)
            
            self.running_mean = self.momentum * self.running_mean.reshape(-1, 1, 1) + \
                               (1 - self.momentum) * mean.squeeze()
            self.running_var = self.momentum * self.running_var.reshape(-1, 1, 1) + \
                              (1 - self.momentum) * var.squeeze()
        else:
            mean = self.running_mean.reshape(1, -1, 1, 1)
            var = self.running_var.reshape(1, -1, 1, 1)
        
        self._mean = mean
        self._var = var
        self._input = x
        
        normalized = (x - mean) / np.sqrt(var + self.epsilon)
        return self.gamma.reshape(1, -1, 1, 1) * normalized + self.beta.reshape(1, -1, 1, 1)
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        gamma = self.gamma.reshape(1, -1, 1, 1)
        normalized = (self._input - self._mean) / np.sqrt(self._var + self.epsilon)
        
        grad_normalized = grad_output * gamma
        N = np.prod(grad_output.shape[2:])
        
        grad_mean = np.sum(grad_normalized, axis=(0, 2, 3), keepdims=True) / N
        grad_var = np.sum(grad_normalized * normalized, axis=(0, 2, 3), keepdims=True) * -0.5 * \
                   np.power(self._var + self.epsilon, -1.5)
        
        grad_input = grad_normalized - grad_mean - normalized * grad_var * 2 / N
        
        return grad_input


class GlobalAvgPool2D(Layer):
    """Global average pooling - reduces each channel to single value."""
    
    def __init__(self):
        super().__init__()

    def build(self, input_shape: Tuple[int, ...]):
        self.input_shape = input_shape
        if len(input_shape) == 4:
            self.output_shape = (input_shape[0], input_shape[1])
        else:
            self.output_shape = (input_shape[0],)
        self.built = True
        return self.output_shape
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        return np.mean(x, axis=(2, 3))


class Reshape(Layer):
    """Reshape layer."""
    
    def __init__(self, target_shape: Tuple[int, ...]):
        super().__init__()
        self.target_shape = target_shape
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        return x.reshape(*x.shape[:1], *self.target_shape)


# ============================================================================
# ACTIVATION FUNCTIONS
# ============================================================================

class ReLU(Layer):
    """ReLU activation layer."""
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        self._mask = x > 0
        return np.maximum(0, x)
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        return grad_output * self._mask


class Sigmoid(Layer):
    """Sigmoid activation layer."""
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        self._output = 1 / (1 + np.exp(-np.clip(x, -500, 500)))
        return self._output
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        return grad_output * self._output * (1 - self._output)


class Tanh(Layer):
    """Tanh activation layer."""
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        self._output = np.tanh(x)
        return self._output
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        return grad_output * (1 - self._output ** 2)


class Softmax(Layer):
    """Softmax activation layer."""
    
    def forward(self, x: np.ndarray, training: bool = False) -> np.ndarray:
        exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
        self._output = exp_x / np.sum(exp_x, axis=-1, keepdims=True)
        return self._output
    
    def backward(self, grad_output: np.ndarray) -> np.ndarray:
        # Simplified gradient for softmax with cross-entropy
        return grad_output
