import os
from einops import rearrange

import torch
import torch.nn as nn

from einops import rearrange, repeat
from functools import lru_cache
import imageio
import uuid
from tqdm import tqdm
import numpy as np
import subprocess
import soundfile as sf
import torchvision
import binascii
import os.path as osp
from skimage import color


VID_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv")
ASPECT_RATIO_627 = {
     '0.26': ([320, 1216], 1), '0.38': ([384, 1024], 1), '0.50': ([448, 896], 1), '0.67': ([512, 768], 1), 
     '0.82': ([576, 704], 1),  '1.00': ([640, 640], 1),  '1.22': ([704, 576], 1), '1.50': ([768, 512], 1), 
     '1.86': ([832, 448], 1),  '2.00': ([896, 448], 1),  '2.50': ([960, 384], 1), '2.83': ([1088, 384], 1), 
     '3.60': ([1152, 320], 1), '3.80': ([1216, 320], 1), '4.00': ([1280, 320], 1)}


ASPECT_RATIO_960 = {
     '0.22': ([448, 2048], 1), '0.29': ([512, 1792], 1), '0.36': ([576, 1600], 1), '0.45': ([640, 1408], 1), 
     '0.55': ([704, 1280], 1), '0.63': ([768, 1216], 1), '0.76': ([832, 1088], 1), '0.88': ([896, 1024], 1), 
     '1.00': ([960, 960], 1), '1.14': ([1024, 896], 1), '1.31': ([1088, 832], 1), '1.50': ([1152, 768], 1), 
     '1.58': ([1216, 768], 1), '1.82': ([1280, 704], 1), '1.91': ([1344, 704], 1), '2.20': ([1408, 640], 1), 
     '2.30': ([1472, 640], 1), '2.67': ([1536, 576], 1), '2.89': ([1664, 576], 1), '3.62': ([1856, 512], 1), 
     '3.75': ([1920, 512], 1)}



def torch_gc():
    torch.cuda.empty_cache()
    torch.cuda.ipc_collect()



def split_token_counts_and_frame_ids(T, token_frame, world_size, rank):

    S = T * token_frame
    split_sizes = [S // world_size + (1 if i < S % world_size else 0) for i in range(world_size)]
    start = sum(split_sizes[:rank])
    end = start + split_sizes[rank]
    counts = [0] * T
    for idx in range(start, end):
        t = idx // token_frame
        counts[t] += 1

    counts_filtered = []
    frame_ids = []
    for t, c in enumerate(counts):
        if c > 0:
            counts_filtered.append(c)
            frame_ids.append(t)
    return counts_filtered, frame_ids


def normalize_and_scale(column, source_range, target_range, epsilon=1e-8):

    source_min, source_max = source_range
    new_min, new_max = target_range
 
    normalized = (column - source_min) / (source_max - source_min + epsilon)
    scaled = normalized * (new_max - new_min) + new_min
    return scaled


# @torch.compile
def calculate_x_ref_attn_map(visual_q, ref_k, ref_target_masks, ref_images_count, mode='mean', attn_bias=None):
    
    ref_k = ref_k.to(visual_q.dtype).to(visual_q.device)
    scale = 1.0 / visual_q.shape[-1] ** 0.5
    visual_q = visual_q * scale
    visual_q = visual_q.transpose(1, 2)
    ref_k = ref_k.transpose(1, 2)
    attn = visual_q @ ref_k.transpose(-2, -1)

    if attn_bias is not None: attn += attn_bias

    x_ref_attn_map_source = attn.softmax(-1) # B, H, x_seqlens, ref_seqlens

    x_ref_attn_maps = []
    ref_target_masks = ref_target_masks.to(visual_q.dtype)
    x_ref_attn_map_source = x_ref_attn_map_source.to(visual_q.dtype)

    for class_idx, ref_target_mask in enumerate(ref_target_masks):
        ref_target_mask = ref_target_mask[None, None, None, ...]
        x_ref_attnmap = x_ref_attn_map_source * ref_target_mask
        x_ref_attnmap = x_ref_attnmap.sum(-1) / ref_target_mask.sum() # B, H, x_seqlens, ref_seqlens --> B, H, x_seqlens
        x_ref_attnmap = x_ref_attnmap.permute(0, 2, 1) # B, x_seqlens, H
       
        if mode == 'mean':
            x_ref_attnmap = x_ref_attnmap.mean(-1) # B, x_seqlens
        elif mode == 'max':
            x_ref_attnmap = x_ref_attnmap.max(-1) # B, x_seqlens
        
        x_ref_attn_maps.append(x_ref_attnmap)
    
    del attn
    del x_ref_attn_map_source

    return torch.concat(x_ref_attn_maps, dim=0)


def get_attn_map_with_target(visual_q, ref_k, shape, ref_target_masks=None, split_num=10, ref_images_count = 0):
    """Args:
        query (torch.tensor): B M H K
        key (torch.tensor): B M H K
        shape (tuple): (N_t, N_h, N_w)
        ref_target_masks: [B, N_h * N_w]
    """

    N_t, N_h, N_w = shape
    
    x_seqlens = N_h * N_w
    ref_k     = ref_k[:, :x_seqlens]
    if ref_images_count > 0 :
        visual_q_shape = visual_q.shape 
        visual_q = visual_q.reshape(visual_q_shape[0], N_t, -1)
        visual_q = visual_q[:, ref_images_count:]
        visual_q = visual_q.reshape(visual_q_shape[0], -1, *visual_q_shape[-2:])

    _, seq_lens, heads, _ = visual_q.shape
    class_num, _ = ref_target_masks.shape
    x_ref_attn_maps = torch.zeros(class_num, seq_lens, dtype=visual_q.dtype, device=visual_q.device)

    split_chunk = heads // split_num
    
    for i in range(split_num):
        x_ref_attn_maps_perhead = calculate_x_ref_attn_map(visual_q[:, :, i*split_chunk:(i+1)*split_chunk, :], ref_k[:, :, i*split_chunk:(i+1)*split_chunk, :], ref_target_masks, ref_images_count)
        x_ref_attn_maps += x_ref_attn_maps_perhead
    
    x_ref_attn_maps /= split_num
    return x_ref_attn_maps


def rotate_half(x):
    x = rearrange(x, "... (d r) -> ... d r", r=2)
    x1, x2 = x.unbind(dim=-1)
    x = torch.stack((-x2, x1), dim=-1)
    return rearrange(x, "... d r -> ... (d r)")


class RotaryPositionalEmbedding1D(nn.Module):

    def __init__(self,
                 head_dim,
                 ):
        super().__init__()
        self.head_dim = head_dim
        self.base = 10000


    @lru_cache(maxsize=32)
    def precompute_freqs_cis_1d(self, pos_indices):

        freqs = 1.0 / (self.base ** (torch.arange(0, self.head_dim, 2)[: (self.head_dim // 2)].float() / self.head_dim))
        freqs = freqs.to(pos_indices.device)
        freqs = torch.einsum("..., f -> ... f", pos_indices.float(), freqs)
        freqs = repeat(freqs, "... n -> ... (n r)", r=2)
        return freqs

    def forward(self, x, pos_indices):
        """1D RoPE.

        Args:
            query (torch.tensor): [B, head, seq, head_dim]
            pos_indices (torch.tensor): [seq,]
        Returns:
            query with the same shape as input.
        """
        freqs_cis = self.precompute_freqs_cis_1d(pos_indices)

        x_ = x.float()

        freqs_cis = freqs_cis.float().to(x.device)
        cos, sin = freqs_cis.cos(), freqs_cis.sin()
        cos, sin = rearrange(cos, 'n d -> 1 1 n d'), rearrange(sin, 'n d -> 1 1 n d')
        x_ = (x_ * cos) + (rotate_half(x_) * sin)

        return x_.type_as(x)
    


def rand_name(length=8, suffix=''):
    name = binascii.b2a_hex(os.urandom(length)).decode('utf-8')
    if suffix:
        if not suffix.startswith('.'):
            suffix = '.' + suffix
        name += suffix
    return name

def cache_video(tensor,
                save_file=None,
                fps=30,
                suffix='.mp4',
                nrow=8,
                normalize=True,
                value_range=(-1, 1),
                retry=5):
    
    # cache file
    cache_file = osp.join('/tmp', rand_name(
        suffix=suffix)) if save_file is None else save_file

    # save to cache
    error = None
    for _ in range(retry):
       
        # preprocess
        tensor = tensor.clamp(min(value_range), max(value_range))
        tensor = torch.stack([
                torchvision.utils.make_grid(
                    u, nrow=nrow, normalize=normalize, value_range=value_range)
                for u in tensor.unbind(2)
            ],
                                 dim=1).permute(1, 2, 3, 0)
        tensor = (tensor * 255).type(torch.uint8).cpu()

        # write video
        writer = imageio.get_writer(cache_file, fps=fps, codec='libx264', quality=10, ffmpeg_params=["-crf", "10"])
        for frame in tensor.numpy():
            writer.append_data(frame)
        writer.close()
        return cache_file

def save_video_ffmpeg(gen_video_samples, save_path, vocal_audio_list, fps=25, quality=5, high_quality_save=False):
    
    def save_video(frames, save_path, fps, quality=9, ffmpeg_params=None):
        writer = imageio.get_writer(
            save_path, fps=fps, quality=quality, ffmpeg_params=ffmpeg_params
        )
        for frame in tqdm(frames, desc="Saving video"):
            frame = np.array(frame)
            writer.append_data(frame)
        writer.close()
    save_path_tmp = save_path + "-temp.mp4"

    if high_quality_save:
        cache_video(
                    tensor=gen_video_samples.unsqueeze(0),
                    save_file=save_path_tmp,
                    fps=fps,
                    nrow=1,
                    normalize=True,
                    value_range=(-1, 1)
                    )
    else:
        video_audio = (gen_video_samples+1)/2 # C T H W
        video_audio = video_audio.permute(1, 2, 3, 0).cpu().numpy()
        video_audio = np.clip(video_audio * 255, 0, 255).astype(np.uint8)  # to [0, 255]
        save_video(video_audio, save_path_tmp, fps=fps, quality=quality)


    # crop audio according to video length
    _, T, _, _ = gen_video_samples.shape
    duration = T / fps
    save_path_crop_audio = save_path + "-cropaudio.wav"
    final_command = [
        "ffmpeg",
        "-i",
        vocal_audio_list[0],
        "-t",
        f'{duration}',
        save_path_crop_audio,
    ]
    subprocess.run(final_command, check=True)

    save_path = save_path + ".mp4"
    if high_quality_save:
        final_command = [
            "ffmpeg",
            "-y",
            "-i", save_path_tmp,
            "-i", save_path_crop_audio,
            "-c:v", "libx264",
            "-crf", "0",
            "-preset", "veryslow",
            "-c:a", "aac", 
            "-shortest",
            save_path,
        ]
        subprocess.run(final_command, check=True)
        os.remove(save_path_tmp)
        os.remove(save_path_crop_audio)
    else:
        final_command = [
            "ffmpeg",
            "-y",
            "-i",
            save_path_tmp,
            "-i",
            save_path_crop_audio,
            "-c:v",
            "libx264",
            "-c:a",
            "aac",
            "-shortest",
            save_path,
        ]
        subprocess.run(final_command, check=True)
        os.remove(save_path_tmp)
        os.remove(save_path_crop_audio)


class MomentumBuffer:
    def __init__(self, momentum: float): 
        self.momentum = momentum 
        self.running_average = 0 
    
    def update(self, update_value: torch.Tensor): 
        new_average = self.momentum * self.running_average 
        self.running_average = update_value + new_average
    


def project( 
        v0: torch.Tensor, # [B, C, T, H, W] 
        v1: torch.Tensor, # [B, C, T, H, W] 
        ): 
    dtype = v0.dtype 
    v0, v1 = v0.double(), v1.double() 
    v1 = torch.nn.functional.normalize(v1, dim=[-1, -2, -3, -4]) 
    v0_parallel = (v0 * v1).sum(dim=[-1, -2, -3, -4], keepdim=True) * v1 
    v0_orthogonal = v0 - v0_parallel
    return v0_parallel.to(dtype), v0_orthogonal.to(dtype)


def adaptive_projected_guidance( 
          diff: torch.Tensor, # [B, C, T, H, W] 
          pred_cond: torch.Tensor, # [B, C, T, H, W] 
          momentum_buffer: MomentumBuffer = None, 
          eta: float = 0.0,
          norm_threshold: float = 55,
          ): 
    if momentum_buffer is not None: 
        momentum_buffer.update(diff) 
        diff = momentum_buffer.running_average
    if norm_threshold > 0: 
        ones = torch.ones_like(diff) 
        diff_norm = diff.norm(p=2, dim=[-1, -2, -3, -4], keepdim=True) 
        print(f"diff_norm: {diff_norm}")
        scale_factor = torch.minimum(ones, norm_threshold / diff_norm) 
        diff = diff * scale_factor 
    diff_parallel, diff_orthogonal = project(diff, pred_cond) 
    normalized_update = diff_orthogonal + eta * diff_parallel
    return normalized_update

def match_and_blend_colors(source_chunk: torch.Tensor, reference_image: torch.Tensor, strength: float) -> torch.Tensor:
    """
    Matches the color of a source video chunk to a reference image and blends with the original.

    Args:
        source_chunk (torch.Tensor): The video chunk to be color-corrected (B, C, T, H, W) in range [-1, 1].
                                     Assumes B=1 (batch size of 1).
        reference_image (torch.Tensor): The reference image (B, C, 1, H, W) in range [-1, 1].
                                        Assumes B=1 and T=1 (single reference frame).
        strength (float): The strength of the color correction (0.0 to 1.0).
                          0.0 means no correction, 1.0 means full correction.

    Returns:
        torch.Tensor: The color-corrected and blended video chunk.
    """
    # print(f"[match_and_blend_colors] Input source_chunk shape: {source_chunk.shape}, reference_image shape: {reference_image.shape}, strength: {strength}")

    if strength == 0.0:
        # print(f"[match_and_blend_colors] Strength is 0, returning original source_chunk.")
        return source_chunk

    if not 0.0 <= strength <= 1.0:
        raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")

    device = source_chunk.device
    dtype = source_chunk.dtype

    # Squeeze batch dimension, permute to T, H, W, C for skimage
    # Source: (1, C, T, H, W) -> (T, H, W, C)
    source_np = source_chunk.squeeze(0).permute(1, 2, 3, 0).cpu().numpy()
    # Reference: (1, C, 1, H, W) -> (H, W, C)
    ref_np = reference_image.squeeze(0).squeeze(1).permute(1, 2, 0).cpu().numpy() # Squeeze T dimension as well

    # Normalize from [-1, 1] to [0, 1] for skimage
    source_np_01 = (source_np + 1.0) / 2.0
    ref_np_01 = (ref_np + 1.0) / 2.0

    # Clip to ensure values are strictly in [0, 1] after potential float precision issues
    source_np_01 = np.clip(source_np_01, 0.0, 1.0)
    ref_np_01 = np.clip(ref_np_01, 0.0, 1.0)

    # Convert reference to Lab
    try:
        ref_lab = color.rgb2lab(ref_np_01)
    except ValueError as e:
        # Handle potential errors if image data is not valid for conversion
        print(f"Warning: Could not convert reference image to Lab: {e}. Skipping color correction for this chunk.")
        return source_chunk


    corrected_frames_np_01 = []
    for i in range(source_np_01.shape[0]): # Iterate over time (T)
        source_frame_rgb_01 = source_np_01[i]
        
        try:
            source_lab = color.rgb2lab(source_frame_rgb_01)
        except ValueError as e:
            print(f"Warning: Could not convert source frame {i} to Lab: {e}. Using original frame.")
            corrected_frames_np_01.append(source_frame_rgb_01)
            continue

        corrected_lab_frame = source_lab.copy()

        # Perform color transfer for L, a, b channels
        for j in range(3): # L, a, b
            mean_src, std_src = source_lab[:, :, j].mean(), source_lab[:, :, j].std()
            mean_ref, std_ref = ref_lab[:, :, j].mean(), ref_lab[:, :, j].std()

            # Avoid division by zero if std_src is 0
            if std_src == 0:
                # If source channel has no variation, keep it as is, but shift by reference mean
                # This case is debatable, could also just copy source or target mean.
                # Shifting by target mean helps if source is flat but target isn't.
                corrected_lab_frame[:, :, j] = mean_ref 
            else:
                corrected_lab_frame[:, :, j] = (corrected_lab_frame[:, :, j] - mean_src) * (std_ref / std_src) + mean_ref
        
        try:
            fully_corrected_frame_rgb_01 = color.lab2rgb(corrected_lab_frame)
        except ValueError as e:
            print(f"Warning: Could not convert corrected frame {i} back to RGB: {e}. Using original frame.")
            corrected_frames_np_01.append(source_frame_rgb_01)
            continue
            
        # Clip again after lab2rgb as it can go slightly out of [0,1]
        fully_corrected_frame_rgb_01 = np.clip(fully_corrected_frame_rgb_01, 0.0, 1.0)

        # Blend with original source frame (in [0,1] RGB)
        blended_frame_rgb_01 = (1 - strength) * source_frame_rgb_01 + strength * fully_corrected_frame_rgb_01
        corrected_frames_np_01.append(blended_frame_rgb_01)

    corrected_chunk_np_01 = np.stack(corrected_frames_np_01, axis=0)

    # Convert back to [-1, 1]
    corrected_chunk_np_minus1_1 = (corrected_chunk_np_01 * 2.0) - 1.0

    # Permute back to (C, T, H, W), add batch dim, and convert to original torch.Tensor type and device
    # (T, H, W, C) -> (C, T, H, W)
    corrected_chunk_tensor = torch.from_numpy(corrected_chunk_np_minus1_1).permute(3, 0, 1, 2).unsqueeze(0)
    corrected_chunk_tensor = corrected_chunk_tensor.contiguous() # Ensure contiguous memory layout
    output_tensor = corrected_chunk_tensor.to(device=device, dtype=dtype)
    # print(f"[match_and_blend_colors] Output tensor shape: {output_tensor.shape}")
    return output_tensor


from skimage import color
from scipy import ndimage
from scipy.ndimage import binary_erosion, distance_transform_edt


def match_and_blend_colors_with_mask(
    source_chunk: torch.Tensor, 
    reference_video: torch.Tensor, 
    mask: torch.Tensor,
    strength: float,
    copy_mode: str = "corrected",  # "corrected", "reference", "source", "progressive_blend"
    source_border_distance: int = 10,
    reference_border_distance: int = 10
) -> torch.Tensor:
    """
    Matches the color of a source video chunk to a reference video using mask-based region sampling.

    Args:
        source_chunk (torch.Tensor): The video chunk to be color-corrected (B, C, T, H, W) in range [-1, 1].
                                     Assumes B=1 (batch size of 1).
        reference_video (torch.Tensor): The reference video (B, C, T, H, W) in range [-1, 1].
                                        Must have same temporal dimension as source_chunk.
        mask (torch.Tensor): Binary mask (B, 1, T, H, W) or (T, H, W) or (H, W) with values 0 and 1.
                            Color correction is applied to pixels where mask=1.
        strength (float): The strength of the color correction (0.0 to 1.0).
                          0.0 means no correction, 1.0 means full correction.
        copy_mode (str): What to do with mask=0 pixels: 
                        "corrected" (keep original), "reference", "source", 
                        "progressive_blend" (double-sided progressive blending near borders).
        source_border_distance (int): Distance in pixels from mask border to sample source video (mask=1 side).
        reference_border_distance (int): Distance in pixels from mask border to sample reference video (mask=0 side).
                                         For "progressive_blend" mode, this also defines the blending falloff distance.

    Returns:
        torch.Tensor: The color-corrected and blended video chunk.
        
    Notes:
        - Color statistics are sampled from border regions to determine source and reference tints
        - Progressive blending creates smooth double-sided transitions:
          * mask=1 side: 60% source + 40% reference at border → 100% source deeper in
          * mask=0 side: 60% reference + 40% source at border → 100% reference deeper in
    """
    
    if strength == 0.0:
        return source_chunk

    if not 0.0 <= strength <= 1.0:
        raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
    
    if copy_mode not in ["corrected", "reference", "source", "progressive_blend"]:
        raise ValueError(f"copy_mode must be 'corrected', 'reference', 'source', or 'progressive_blend', got {copy_mode}")

    device = source_chunk.device
    dtype = source_chunk.dtype
    B, C, T, H, W = source_chunk.shape

    # Handle different mask dimensions
    if mask.dim() == 2:  # (H, W)
        mask = mask.unsqueeze(0).unsqueeze(0).unsqueeze(0).expand(B, 1, T, H, W)
    elif mask.dim() == 3:  # (T, H, W)
        mask = mask.unsqueeze(0).unsqueeze(0).expand(B, 1, T, H, W)
    elif mask.dim() == 4:  # (B, T, H, W) - missing channel dim
        mask = mask.unsqueeze(1)
    # mask should now be (B, 1, T, H, W)

    # Convert to numpy for processing
    source_np = source_chunk.squeeze(0).permute(1, 2, 3, 0).cpu().numpy()  # (T, H, W, C)
    reference_np = reference_video.squeeze(0).permute(1, 2, 3, 0).cpu().numpy()  # (T, H, W, C)
    mask_np = mask.squeeze(0).squeeze(0).cpu().numpy()  # (T, H, W)

    # Normalize from [-1, 1] to [0, 1] for skimage
    source_np_01 = (source_np + 1.0) / 2.0
    reference_np_01 = (reference_np + 1.0) / 2.0
    
    # Clip to ensure values are in [0, 1]
    source_np_01 = np.clip(source_np_01, 0.0, 1.0)
    reference_np_01 = np.clip(reference_np_01, 0.0, 1.0)

    corrected_frames_np_01 = []
    
    for t in range(T):
        source_frame = source_np_01[t]  # (H, W, C)
        reference_frame = reference_np_01[t]  # (H, W, C)
        frame_mask = mask_np[t]  # (H, W)
        
        # Find mask borders and create distance maps
        border_regions = get_border_sampling_regions(frame_mask, source_border_distance, reference_border_distance)
        source_sample_region = border_regions['source_region']  # mask=1 side
        reference_sample_region = border_regions['reference_region']  # mask=0 side
        
        # Sample pixels for color statistics
        try:
            source_stats = compute_color_stats(source_frame, source_sample_region)
            reference_stats = compute_color_stats(reference_frame, reference_sample_region)
        except ValueError as e:
            print(f"Warning: Could not compute color statistics for frame {t}: {e}. Using original frame.")
            corrected_frames_np_01.append(source_frame)
            continue
        
        # Apply color correction to mask=1 area and handle mask=0 area based on copy_mode
        corrected_frame = apply_color_correction_with_mask(
            source_frame, frame_mask, source_stats, reference_stats, strength
        )
        
        # Handle mask=0 pixels based on copy_mode
        if copy_mode == "reference":
            corrected_frame = apply_copy_with_mask(corrected_frame, reference_frame, frame_mask, "reference")
        elif copy_mode == "source":
            corrected_frame = apply_copy_with_mask(corrected_frame, source_frame, frame_mask, "source")
        elif copy_mode == "progressive_blend":
            # Apply progressive blending in mask=1 border area (source side)
            corrected_frame = apply_progressive_blend_in_corrected_area(
                corrected_frame, reference_frame, frame_mask, 
                border_regions['source_region'], border_regions['source_distances'], 
                border_regions['reference_region'], source_border_distance
            )
            # Copy reference pixels to mask=0 area first
            corrected_frame = apply_copy_with_mask(corrected_frame, reference_frame, frame_mask, "reference")
            # Then apply progressive blending in mask=0 border area (reference side)
            corrected_frame = apply_progressive_blend_in_reference_area(
                corrected_frame, source_frame, frame_mask, 
                border_regions['reference_region'], border_regions['reference_distances'], 
                reference_border_distance
            )
            
        corrected_frames_np_01.append(corrected_frame)

    corrected_chunk_np_01 = np.stack(corrected_frames_np_01, axis=0)

    # Convert back to [-1, 1] and return to tensor format
    corrected_chunk_np_minus1_1 = (corrected_chunk_np_01 * 2.0) - 1.0
    corrected_chunk_tensor = torch.from_numpy(corrected_chunk_np_minus1_1).permute(3, 0, 1, 2).unsqueeze(0)
    corrected_chunk_tensor = corrected_chunk_tensor.contiguous()
    output_tensor = corrected_chunk_tensor.to(device=device, dtype=dtype)
    
    return output_tensor


def get_border_sampling_regions(mask, source_border_distance, reference_border_distance):
    """
    Create regions for sampling near mask borders with separate distances for source and reference.
    
    Args:
        mask: Binary mask (H, W) with 0s and 1s
        source_border_distance: Distance from border to include in source sampling (mask=1 side)
        reference_border_distance: Distance from border to include in reference sampling (mask=0 side)
        
    Returns:
        Dict with sampling regions and distance maps for blending
    """
    # Convert to boolean for safety
    mask_bool = mask.astype(bool)
    
    # Distance from mask=0 regions (distance into mask=1 areas from border)
    dist_from_mask0 = distance_transform_edt(mask_bool)
    
    # Distance from mask=1 regions (distance into mask=0 areas from border)  
    dist_from_mask1 = distance_transform_edt(~mask_bool)
    
    # Source region: mask=1 pixels within source_border_distance of mask=0 pixels
    source_region = mask_bool & (dist_from_mask0 <= source_border_distance)
    
    # Reference region: mask=0 pixels within reference_border_distance of mask=1 pixels
    reference_region = (~mask_bool) & (dist_from_mask1 <= reference_border_distance)
    
    return {
        'source_region': source_region,
        'reference_region': reference_region,
        'source_distances': dist_from_mask0,  # Distance into mask=1 from border
        'reference_distances': dist_from_mask1  # Distance into mask=0 from border
    }


def compute_color_stats(image, sample_region):
    """
    Compute color statistics (mean and std) for Lab channels in the sampling region.
    
    Args:
        image: RGB image (H, W, C) in range [0, 1]
        sample_region: Boolean mask (H, W) indicating pixels to sample
        
    Returns:
        Dict with 'mean' and 'std' for Lab components
    """
    if not np.any(sample_region):
        raise ValueError("No pixels in sampling region")
    
    # Convert to Lab
    try:
        image_lab = color.rgb2lab(image)
    except ValueError as e:
        raise ValueError(f"Could not convert image to Lab: {e}")
    
    # Extract pixels in sampling region
    sampled_pixels = image_lab[sample_region]  # (N, 3) where N is number of sampled pixels
    
    # Compute statistics for each Lab channel
    stats = {
        'mean': np.mean(sampled_pixels, axis=0),  # (3,) for L, a, b
        'std': np.std(sampled_pixels, axis=0)     # (3,) for L, a, b
    }
    
    return stats


def apply_color_correction_with_mask(source_frame, mask, source_stats, reference_stats, strength):
    """
    Apply color correction to pixels where mask=1.
    
    Args:
        source_frame: RGB image (H, W, C) in range [0, 1]
        mask: Binary mask (H, W)
        source_stats: Color statistics from source sampling region
        reference_stats: Color statistics from reference sampling region
        strength: Blending strength
        
    Returns:
        Corrected RGB image (H, W, C)
    """
    try:
        source_lab = color.rgb2lab(source_frame)
    except ValueError as e:
        print(f"Warning: Could not convert source frame to Lab: {e}. Using original frame.")
        return source_frame
    
    corrected_lab = source_lab.copy()
    correction_region = (mask == 1)  # Apply correction to mask=1 pixels
    
    # Apply color transfer to pixels where mask=1
    for c in range(3):  # L, a, b channels
        mean_src = source_stats['mean'][c]
        std_src = source_stats['std'][c]
        mean_ref = reference_stats['mean'][c]
        std_ref = reference_stats['std'][c]
        
        if std_src == 0:
            # Handle case where source channel has no variation
            corrected_lab[correction_region, c] = mean_ref
        else:
            # Standard color transfer formula
            corrected_lab[correction_region, c] = (
                (corrected_lab[correction_region, c] - mean_src) * (std_ref / std_src) + mean_ref
            )
    
    try:
        fully_corrected_rgb = color.lab2rgb(corrected_lab)
    except ValueError as e:
        print(f"Warning: Could not convert corrected frame back to RGB: {e}. Using original frame.")
        return source_frame
    
    # Clip to [0, 1]
    fully_corrected_rgb = np.clip(fully_corrected_rgb, 0.0, 1.0)
    
    # Blend with original (only in correction region)
    result = source_frame.copy()
    result[correction_region] = (
        (1 - strength) * source_frame[correction_region] + 
        strength * fully_corrected_rgb[correction_region]
    )
    
    return result


def apply_progressive_blend_in_corrected_area(corrected_frame, reference_frame, mask, source_region, source_distances, reference_region, source_border_distance):
    """
    Apply progressive blending in the corrected area (mask=1) near the border.
    
    Args:
        corrected_frame: RGB image (H, W, C) - the color-corrected source frame
        reference_frame: RGB image (H, W, C) - the reference frame
        mask: Binary mask (H, W)
        source_region: Boolean mask (H, W) indicating the source blending region (mask=1 near border)
        source_distances: Distance map (H, W) into mask=1 area from mask=0 border
        reference_region: Boolean mask (H, W) indicating the reference sampling region (mask=0 near border)
        source_border_distance: Maximum distance for source blending
        
    Returns:
        Blended RGB image (H, W, C)
        
            Notes:
        - Each source pixel blends with its closest reference border pixel (for speed)
        - At mask border: 60% source + 40% reference
        - Deeper into mask=1 area: 100% corrected source
    """
    result = corrected_frame.copy()
    
    # Blend in the source region (mask=1 pixels near border)
    blend_region = source_region
    
    if np.any(blend_region):
        # Find immediate border pixels (mask=0 pixels adjacent to mask=1 pixels)
        # This is much faster than using the entire reference region
        from scipy.ndimage import binary_dilation
        
        # Dilate mask=1 by 1 pixel, then find intersection with mask=0
        mask_1_dilated = binary_dilation(mask == 1, structure=np.ones((3, 3)))
        border_pixels = (mask == 0) & mask_1_dilated
        
        if np.any(border_pixels):
            # Find closest border pixel for each source pixel
            source_coords = np.column_stack(np.where(blend_region))  # (N, 2) - y, x coordinates
            border_coords = np.column_stack(np.where(border_pixels))  # (M, 2) - much smaller set!
            
            # For each source pixel, find closest border pixel
            from scipy.spatial.distance import cdist
            distances_matrix = cdist(source_coords, border_coords, metric='euclidean')
            closest_border_indices = np.argmin(distances_matrix, axis=1)
            
            # Normalize source distances for blending weights
            min_distance_in_region = np.min(source_distances[blend_region])
            max_distance_in_region = np.max(source_distances[blend_region])
            
            if max_distance_in_region > min_distance_in_region:
                # Calculate blend weights: 0.4 at border (60% source + 40% reference), 0.0 at max distance (100% source)
                source_dist_values = source_distances[blend_region]
                normalized_distances = (source_dist_values - min_distance_in_region) / (max_distance_in_region - min_distance_in_region)
                blend_weights = 0.4 * (1.0 - normalized_distances)  # Start with 40% reference influence at border
                
                # Apply blending with closest border pixels
                for i, (source_y, source_x) in enumerate(source_coords):
                    closest_border_idx = closest_border_indices[i]
                    border_y, border_x = border_coords[closest_border_idx]
                    
                    weight = blend_weights[i]
                    # Blend with closest border pixel
                    result[source_y, source_x] = (
                        (1.0 - weight) * corrected_frame[source_y, source_x] + 
                        weight * reference_frame[border_y, border_x]
                    )
    
    return result


def apply_progressive_blend_in_reference_area(reference_frame, source_frame, mask, reference_region, reference_distances, reference_border_distance):
    """
    Apply progressive blending in the reference area (mask=0) near the border.
    
    Args:
        reference_frame: RGB image (H, W, C) - the reference frame with copied reference pixels
        source_frame: RGB image (H, W, C) - the original source frame
        mask: Binary mask (H, W)
        reference_region: Boolean mask (H, W) indicating the reference blending region (mask=0 near border)
        reference_distances: Distance map (H, W) into mask=0 area from mask=1 border
        reference_border_distance: Maximum distance for reference blending
        
    Returns:
        Blended RGB image (H, W, C)
        
    Notes:
        - Each reference pixel blends with its closest source border pixel (for speed)
        - At mask border: 60% reference + 40% source
        - Deeper into mask=0 area: 100% reference
    """
    result = reference_frame.copy()
    
    # Blend in the reference region (mask=0 pixels near border)
    blend_region = reference_region
    
    if np.any(blend_region):
        # Find immediate border pixels (mask=1 pixels adjacent to mask=0 pixels)
        from scipy.ndimage import binary_dilation
        
        # Dilate mask=0 by 1 pixel, then find intersection with mask=1
        mask_0_dilated = binary_dilation(mask == 0, structure=np.ones((3, 3)))
        source_border_pixels = (mask == 1) & mask_0_dilated
        
        if np.any(source_border_pixels):
            # Find closest source border pixel for each reference pixel
            reference_coords = np.column_stack(np.where(blend_region))  # (N, 2) - y, x coordinates
            source_border_coords = np.column_stack(np.where(source_border_pixels))  # (M, 2)
            
            # For each reference pixel, find closest source border pixel
            from scipy.spatial.distance import cdist
            distances_matrix = cdist(reference_coords, source_border_coords, metric='euclidean')
            closest_source_indices = np.argmin(distances_matrix, axis=1)
            
            # Normalize reference distances for blending weights
            min_distance_in_region = np.min(reference_distances[blend_region])
            max_distance_in_region = np.max(reference_distances[blend_region])
            
            if max_distance_in_region > min_distance_in_region:
                # Calculate blend weights: 0.4 at border (60% reference + 40% source), 0.0 at max distance (100% reference)
                reference_dist_values = reference_distances[blend_region]
                normalized_distances = (reference_dist_values - min_distance_in_region) / (max_distance_in_region - min_distance_in_region)
                blend_weights = 0.4 * (1.0 - normalized_distances)  # Start with 40% source influence at border
                
                # Apply blending with closest source border pixels
                for i, (ref_y, ref_x) in enumerate(reference_coords):
                    closest_source_idx = closest_source_indices[i]
                    source_y, source_x = source_border_coords[closest_source_idx]
                    
                    weight = blend_weights[i]
                    # Blend: weight=0.4 means 60% reference + 40% source at border
                    result[ref_y, ref_x] = (
                        (1.0 - weight) * reference_frame[ref_y, ref_x] + 
                        weight * source_frame[source_y, source_x]
                    )
    
    return result


def apply_copy_with_mask(source_frame, reference_frame, mask, copy_source):
    """
    Copy pixels to mask=0 regions based on copy_source parameter.
    
    Args:
        source_frame: RGB image (H, W, C)
        reference_frame: RGB image (H, W, C)
        mask: Binary mask (H, W)
        copy_source: "reference" or "source"
        
    Returns:
        Combined RGB image (H, W, C)
    """
    result = source_frame.copy()
    mask_0_region = (mask == 0)
    
    if copy_source == "reference":
        result[mask_0_region] = reference_frame[mask_0_region]
    # If "source", we keep the original source pixels (no change needed)
    
    return result