import cv2
import numpy as np
from pathlib import Path
import math
from typing import Optional, Union, List, Tuple, Dict

# --- Basic Power-of-Two Utilities ---

def is_power_of_two(n: int) -> bool:
    """Checks if a number is a power of two."""
    return (n > 0) and (n & (n - 1) == 0)

def get_nearest_pot(value: int) -> int:
    """Finds the nearest power of two to the given value."""
    if value <= 0:
        return 1  # POT must be positive, return 1 as a fallback
    if is_power_of_two(value):
        return value

    lower_pot = 1 << (value.bit_length() - 1)
    upper_pot = 1 << value.bit_length()

    if (value - lower_pot) < (upper_pot - value):
        return lower_pot
    else:
        return upper_pot

def get_nearest_power_of_two_downscale(value: int) -> int:
    """
    Finds the nearest power of two that is less than or equal to the given value.
    If the value is already a power of two, it returns the value itself.
    Returns 1 if the value is less than 1.
    """
    if value < 1:
        return 1
    if is_power_of_two(value):
        return value
    # Find the largest power of two strictly less than value,
    # unless value itself is POT.
    # (1 << (value.bit_length() - 1)) achieves this.
    # Example: value=7 (0111, bl=3), 1<<2 = 4.
    # Example: value=8 (1000, bl=4), 1<<3 = 8.
    # Example: value=9 (1001, bl=4), 1<<3 = 8.
    return 1 << (value.bit_length() - 1)
# --- Dimension Calculation ---

def calculate_target_dimensions(
    original_width: int,
    original_height: int,
    target_width: Optional[int] = None,
    target_height: Optional[int] = None,
    resize_mode: str = "fit",  # e.g., "fit", "stretch", "max_dim_pot"
    ensure_pot: bool = False,
    allow_upscale: bool = False,
    target_max_dim_for_pot_mode: Optional[int] = None # Specific for "max_dim_pot"
) -> Tuple[int, int]:
    """
    Calculates target dimensions based on various modes and constraints.

    Args:
        original_width: Original width of the image.
        original_height: Original height of the image.
        target_width: Desired target width.
        target_height: Desired target height.
        resize_mode:
            - "fit": Scales to fit within target_width/target_height, maintaining aspect ratio.
                     Requires at least one of target_width or target_height.
            - "stretch": Scales to exactly target_width and target_height, ignoring aspect ratio.
                         Requires both target_width and target_height.
            - "max_dim_pot": Scales to fit target_max_dim_for_pot_mode while maintaining aspect ratio,
                             then finds nearest POT for each dimension. Requires target_max_dim_for_pot_mode.
        ensure_pot: If True, final dimensions will be adjusted to the nearest power of two.
        allow_upscale: If False, dimensions will not exceed original dimensions unless ensure_pot forces it.
        target_max_dim_for_pot_mode: Max dimension to use when resize_mode is "max_dim_pot".

    Returns:
        A tuple (new_width, new_height).
    """
    if original_width <= 0 or original_height <= 0:
        # Fallback for invalid original dimensions
        fallback_dim = 1
        if ensure_pot:
            if target_width and target_height:
                fallback_dim = get_nearest_pot(max(target_width, target_height, 1))
            elif target_width:
                fallback_dim = get_nearest_pot(target_width)
            elif target_height:
                fallback_dim = get_nearest_pot(target_height)
            elif target_max_dim_for_pot_mode:
                 fallback_dim = get_nearest_pot(target_max_dim_for_pot_mode)
            else: # Default POT if no target given
                fallback_dim = 256
            return (fallback_dim, fallback_dim)
        return (target_width or 1, target_height or 1)


    w, h = original_width, original_height

    if resize_mode == "max_dim_pot":
        if target_max_dim_for_pot_mode is None:
            raise ValueError("target_max_dim_for_pot_mode must be provided for 'max_dim_pot' resize_mode.")
        
        # Logic adapted from old processing_engine.calculate_target_dimensions
        ratio = w / h
        if ratio > 1:  # Width is dominant
            scaled_w = target_max_dim_for_pot_mode
            scaled_h = max(1, round(scaled_w / ratio))
        else:  # Height is dominant or square
            scaled_h = target_max_dim_for_pot_mode
            scaled_w = max(1, round(scaled_h * ratio))
        
        # Upscale check for this mode is implicitly handled by target_max_dim
        # If ensure_pot is true (as it was in the original logic), it's applied here
        # For this mode, ensure_pot is effectively always true for the final step
        w = get_nearest_pot(scaled_w)
        h = get_nearest_pot(scaled_h)
        return int(w), int(h)

    elif resize_mode == "fit":
        if target_width is None and target_height is None:
            raise ValueError("At least one of target_width or target_height must be provided for 'fit' mode.")
        
        if target_width and target_height:
            ratio_orig = w / h
            ratio_target = target_width / target_height
            if ratio_orig > ratio_target: # Original is wider than target aspect
                w_new = target_width
                h_new = max(1, round(w_new / ratio_orig))
            else: # Original is taller or same aspect
                h_new = target_height
                w_new = max(1, round(h_new * ratio_orig))
        elif target_width:
            w_new = target_width
            h_new = max(1, round(w_new / (w / h)))
        else: # target_height is not None
            h_new = target_height
            w_new = max(1, round(h_new * (w / h)))
        w, h = w_new, h_new

    elif resize_mode == "stretch":
        if target_width is None or target_height is None:
            raise ValueError("Both target_width and target_height must be provided for 'stretch' mode.")
        w, h = target_width, target_height
    
    else:
        raise ValueError(f"Unsupported resize_mode: {resize_mode}")

    if not allow_upscale:
        if w > original_width: w = original_width
        if h > original_height: h = original_height
    
    if ensure_pot:
        w = get_nearest_pot(w)
        h = get_nearest_pot(h)
        # Re-check upscale if POT adjustment made it larger than original and not allowed
        if not allow_upscale:
            if w > original_width: w = get_nearest_pot(original_width) # Get closest POT to original
            if h > original_height: h = get_nearest_pot(original_height)


    return int(max(1, w)), int(max(1, h))


# --- Image Statistics ---

def calculate_image_stats(image_data: np.ndarray) -> Optional[Dict]:
    """
    Calculates min, max, mean for a given numpy image array.
    Handles grayscale and multi-channel images. Converts to float64 for calculation.
    Normalizes uint8/uint16 data to 0-1 range before calculating stats.
    """
    if image_data is None:
        return None
    try:
        data_float = image_data.astype(np.float64)

        if image_data.dtype == np.uint16:
            data_float /= 65535.0
        elif image_data.dtype == np.uint8:
            data_float /= 255.0
        
        stats = {}
        if len(data_float.shape) == 2:  # Grayscale (H, W)
            stats["min"] = float(np.min(data_float))
            stats["max"] = float(np.max(data_float))
            stats["mean"] = float(np.mean(data_float))
            stats["median"] = float(np.median(data_float))
        elif len(data_float.shape) == 3:  # Color (H, W, C)
            stats["min"] = [float(v) for v in np.min(data_float, axis=(0, 1))]
            stats["max"] = [float(v) for v in np.max(data_float, axis=(0, 1))]
            stats["mean"] = [float(v) for v in np.mean(data_float, axis=(0, 1))]
            stats["median"] = [float(v) for v in np.median(data_float, axis=(0, 1))]
        else:
            return None # Unsupported shape
        return stats
    except Exception:
        return {"error": "Error calculating image stats"}

# --- Aspect Ratio String ---

def normalize_aspect_ratio_change(original_width: int, original_height: int, resized_width: int, resized_height: int, decimals: int = 2) -> str:
    """
    Calculates the aspect ratio change string (e.g., "EVEN", "X133").
    """
    if original_width <= 0 or original_height <= 0:
        return "InvalidInput"
    if resized_width <= 0 or resized_height <= 0:
        return "InvalidResize"

    width_change_percentage = ((resized_width - original_width) / original_width) * 100
    height_change_percentage = ((resized_height - original_height) / original_height) * 100

    normalized_width_change = width_change_percentage / 100
    normalized_height_change = height_change_percentage / 100

    normalized_width_change = min(max(normalized_width_change + 1, 0), 2)
    normalized_height_change = min(max(normalized_height_change + 1, 0), 2)
    
    epsilon = 1e-9
    if abs(normalized_width_change) < epsilon and abs(normalized_height_change) < epsilon:
         closest_value_to_one = 1.0
    elif abs(normalized_width_change) < epsilon:
         closest_value_to_one = abs(normalized_height_change)
    elif abs(normalized_height_change) < epsilon:
         closest_value_to_one = abs(normalized_width_change)
    else:
         closest_value_to_one = min(abs(normalized_width_change), abs(normalized_height_change))

    scale_factor = 1 / (closest_value_to_one + epsilon) if abs(closest_value_to_one) < epsilon else 1 / closest_value_to_one

    scaled_normalized_width_change = scale_factor * normalized_width_change
    scaled_normalized_height_change = scale_factor * normalized_height_change

    output_width = round(scaled_normalized_width_change, decimals)
    output_height = round(scaled_normalized_height_change, decimals)

    if abs(output_width - 1.0) < epsilon: output_width = 1
    if abs(output_height - 1.0) < epsilon: output_height = 1

    # Helper to format the number part
    def format_value(val, dec):
        # Multiply by 10^decimals, convert to int to keep trailing zeros in effect
        # e.g. val=1.1, dec=2 -> 1.1 * 100 = 110
        # e.g. val=1.0, dec=2 -> 1.0 * 100 = 100 (though this might become "1" if it's exactly 1.0 before this)
        # The existing logic already handles output_width/height being 1.0 to produce "EVEN" or skip a component.
        # This formatting is for when output_width/height is NOT 1.0.
        return str(int(round(val * (10**dec))))

    if abs(output_width - output_height) < epsilon: # Handles original square or aspect maintained
        output = "EVEN"
    elif output_width != 1 and abs(output_height - 1.0) < epsilon : # Width changed, height maintained relative to width
        output = f"X{format_value(output_width, decimals)}"
    elif output_height != 1 and abs(output_width - 1.0) < epsilon: # Height changed, width maintained relative to height
        output = f"Y{format_value(output_height, decimals)}"
    else: # Both changed relative to each other
        output = f"X{format_value(output_width, decimals)}Y{format_value(output_height, decimals)}"
    return output

# --- Image Loading, Conversion, Resizing ---

def load_image(image_path: Union[str, Path], read_flag: int = cv2.IMREAD_UNCHANGED) -> Optional[np.ndarray]:
    """Loads an image from the specified path. Converts BGR/BGRA to RGB/RGBA if color."""
    try:
        img = cv2.imread(str(image_path), read_flag)
        if img is None:
            # print(f"Warning: Failed to load image: {image_path}") # Optional: for debugging utils
            return None

        # Ensure RGB/RGBA for color images
        if len(img.shape) == 3:
            if img.shape[2] == 4: # BGRA from OpenCV
                img = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
            elif img.shape[2] == 3: # BGR from OpenCV
                img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        return img
    except Exception: # as e:
        # print(f"Error loading image {image_path}: {e}") # Optional: for debugging utils
        return None

def convert_bgr_to_rgb(image: np.ndarray) -> np.ndarray:
    """Converts an image from BGR/BGRA to RGB/RGBA color space."""
    if image is None or len(image.shape) < 3:
        return image # Return as is if not a color image or None
    
    if image.shape[2] == 4: # BGRA
        return cv2.cvtColor(image, cv2.COLOR_BGRA2RGBA) # Keep alpha, convert to RGBA
    elif image.shape[2] == 3: # BGR
        return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
    return image # Return as is if not 3 or 4 channels

def convert_rgb_to_bgr(image: np.ndarray) -> np.ndarray:
    """Converts an image from RGB/RGBA to BGR/BGRA color space."""
    if image is None or len(image.shape) < 3:
        return image # Return as is if not a color image or None
    
    if image.shape[2] == 4: # RGBA
        return cv2.cvtColor(image, cv2.COLOR_RGBA2BGRA)
    elif image.shape[2] == 3: # RGB
        return cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
    return image # Return as is if not 3 or 4 channels


def resize_image(image: np.ndarray, target_width: int, target_height: int, interpolation: Optional[int] = None) -> np.ndarray:
    """Resizes an image to target_width and target_height."""
    if image is None:
        raise ValueError("Cannot resize a None image.")
    if target_width <= 0 or target_height <= 0:
        raise ValueError("Target width and height must be positive.")

    original_height, original_width = image.shape[:2]

    if interpolation is None:
        # Default interpolation: Lanczos for downscaling, Cubic for upscaling/same
        if (target_width * target_height) < (original_width * original_height):
            interpolation = cv2.INTER_LANCZOS4
        else:
            interpolation = cv2.INTER_CUBIC
            
    return cv2.resize(image, (target_width, target_height), interpolation=interpolation)

# --- Image Saving ---

def save_image(
    image_path: Union[str, Path],
    image_data: np.ndarray,
    output_format: Optional[str] = None, # e.g. "png", "jpg", "exr"
    output_dtype_target: Optional[np.dtype] = None, # e.g. np.uint8, np.uint16, np.float16
    params: Optional[List[int]] = None,
    convert_to_bgr_before_save: bool = True # True for most formats except EXR
) -> bool:
    """
    Saves image data to a file. Handles data type and color space conversions.

    Args:
        image_path: Path to save the image.
        image_data: NumPy array of the image.
        output_format: Desired output format (e.g., 'png', 'jpg'). If None, derived from extension.
        output_dtype_target: Target NumPy dtype for saving (e.g., np.uint8, np.uint16).
                             If None, tries to use image_data.dtype or a sensible default.
        params: OpenCV imwrite parameters (e.g., [cv2.IMWRITE_JPEG_QUALITY, 90]).
        convert_to_bgr_before_save: If True and image is 3-channel, converts RGB to BGR.
                                   Set to False for formats like EXR that expect RGB.

    Returns:
        True if saving was successful, False otherwise.
    """
    if image_data is None:
        return False
    
    img_to_save = image_data.copy()
    path_obj = Path(image_path)
    path_obj.parent.mkdir(parents=True, exist_ok=True)

    # 1. Data Type Conversion
    if output_dtype_target is not None:
        if output_dtype_target == np.uint8 and img_to_save.dtype != np.uint8:
            if img_to_save.dtype == np.uint16: img_to_save = (img_to_save.astype(np.float32) / 65535.0 * 255.0).astype(np.uint8)
            elif img_to_save.dtype in [np.float16, np.float32, np.float64]: img_to_save = (np.clip(img_to_save, 0.0, 1.0) * 255.0).astype(np.uint8)
            else: img_to_save = img_to_save.astype(np.uint8)
        elif output_dtype_target == np.uint16 and img_to_save.dtype != np.uint16:
            if img_to_save.dtype == np.uint8: img_to_save = (img_to_save.astype(np.float32) / 255.0 * 65535.0).astype(np.uint16) # More accurate
            elif img_to_save.dtype in [np.float16, np.float32, np.float64]: img_to_save = (np.clip(img_to_save, 0.0, 1.0) * 65535.0).astype(np.uint16)
            else: img_to_save = img_to_save.astype(np.uint16)
        elif output_dtype_target == np.float16 and img_to_save.dtype != np.float16:
            if img_to_save.dtype == np.uint16: img_to_save = (img_to_save.astype(np.float32) / 65535.0).astype(np.float16)
            elif img_to_save.dtype == np.uint8: img_to_save = (img_to_save.astype(np.float32) / 255.0).astype(np.float16)
            elif img_to_save.dtype in [np.float32, np.float64]: img_to_save = img_to_save.astype(np.float16)
            # else: cannot convert to float16 easily
        elif output_dtype_target == np.float32 and img_to_save.dtype != np.float32:
             if img_to_save.dtype == np.uint16: img_to_save = (img_to_save.astype(np.float32) / 65535.0)
             elif img_to_save.dtype == np.uint8: img_to_save = (img_to_save.astype(np.float32) / 255.0)
             elif img_to_save.dtype == np.float16: img_to_save = img_to_save.astype(np.float32)


    # 2. Color Space Conversion (Internal RGB/RGBA -> BGR/BGRA for OpenCV)
    # Input `image_data` is assumed to be in RGB/RGBA format (due to `load_image` changes).
    # OpenCV's `imwrite` typically expects BGR/BGRA for formats like PNG, JPG.
    # EXR format usually expects RGB/RGBA.
    # The `convert_to_bgr_before_save` flag controls this behavior.
    current_format = output_format if output_format else path_obj.suffix.lower().lstrip('.')
    
    if convert_to_bgr_before_save and current_format != 'exr':
        # If image is 3-channel (RGB) or 4-channel (RGBA), convert to BGR/BGRA.
        if len(img_to_save.shape) == 3 and (img_to_save.shape[2] == 3 or img_to_save.shape[2] == 4):
            img_to_save = convert_rgb_to_bgr(img_to_save) # Handles RGB->BGR and RGBA->BGRA
    # If `convert_to_bgr_before_save` is False or format is 'exr',
    # the image (assumed RGB/RGBA) is saved as is.

    # 3. Save Image
    try:
        if params:
            cv2.imwrite(str(path_obj), img_to_save, params)
        else:
            cv2.imwrite(str(path_obj), img_to_save)
        return True
    except Exception: # as e:
        # print(f"Error saving image {path_obj}: {e}") # Optional: for debugging utils
        return False