# Copyright (c) ModelScope Contributors. All rights reserved.
import base64
import math
import numpy as np
import os
import re
import requests
import torch
from io import BytesIO
from PIL import Image
from requests.adapters import HTTPAdapter
from typing import Any, Callable, List, TypeVar, Union
from urllib3.util.retry import Retry

from swift.utils import get_env_args

# >>> internvl
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)


def _build_transform(input_size):
    import torchvision.transforms as T
    from torchvision.transforms.functional import InterpolationMode
    MEAN, STD = IMAGENET_MEAN, IMAGENET_STD
    transform = T.Compose([
        T.Lambda(lambda img: img.convert('RGB') if img.mode != 'RGB' else img),
        T.Resize((input_size, input_size), interpolation=InterpolationMode.BICUBIC),
        T.ToTensor(),
        T.Normalize(mean=MEAN, std=STD)
    ])
    return transform


def _find_closest_aspect_ratio(aspect_ratio, target_ratios, width, height, image_size):
    best_ratio_diff = float('inf')
    best_ratio = (1, 1)
    area = width * height
    for ratio in target_ratios:
        target_aspect_ratio = ratio[0] / ratio[1]
        ratio_diff = abs(aspect_ratio - target_aspect_ratio)
        if ratio_diff < best_ratio_diff:
            best_ratio_diff = ratio_diff
            best_ratio = ratio
        elif ratio_diff == best_ratio_diff:
            if area > 0.5 * image_size * image_size * ratio[0] * ratio[1]:
                best_ratio = ratio
    return best_ratio


def _dynamic_preprocess(image, min_num=1, max_num=12, image_size=448, use_thumbnail=False):
    orig_width, orig_height = image.size
    aspect_ratio = orig_width / orig_height

    # calculate the existing image aspect ratio
    target_ratios = set((i, j) for n in range(min_num, max_num + 1) for i in range(1, n + 1) for j in range(1, n + 1)
                        if min_num <= i * j <= max_num)
    target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1])

    # find the closest aspect ratio to the target
    target_aspect_ratio = _find_closest_aspect_ratio(aspect_ratio, target_ratios, orig_width, orig_height, image_size)

    # calculate the target width and height
    target_width = image_size * target_aspect_ratio[0]
    target_height = image_size * target_aspect_ratio[1]
    blocks = target_aspect_ratio[0] * target_aspect_ratio[1]

    # resize the image
    resized_img = image.resize((target_width, target_height))
    processed_images = []
    for i in range(blocks):
        box = ((i % (target_width // image_size)) * image_size, (i // (target_width // image_size)) * image_size,
               ((i % (target_width // image_size)) + 1) * image_size, ((i //
                                                                        (target_width // image_size)) + 1) * image_size)
        # split the image
        split_img = resized_img.crop(box)
        processed_images.append(split_img)
    assert len(processed_images) == blocks
    if use_thumbnail and len(processed_images) != 1:
        thumbnail_img = image.resize((image_size, image_size))
        processed_images.append(thumbnail_img)
    return processed_images


# <<< internvl


def rescale_image(img: Image.Image, max_pixels: int) -> Image.Image:
    import torchvision.transforms as T
    width = img.width
    height = img.height
    if max_pixels is None or max_pixels <= 0 or width * height <= max_pixels:
        return img

    ratio = width / height
    height_scaled = math.sqrt(max_pixels / ratio)
    width_scaled = height_scaled * ratio
    return T.Resize((int(height_scaled), int(width_scaled)))(img)


_T = TypeVar('_T')


def _check_path(path: str) -> Union[str, None]:
    """If it is a path, return the string; if it is base64, return None."""
    MAX_PATH_HEURISTIC = 2000
    if len(path) > MAX_PATH_HEURISTIC:
        return
    if os.path.exists(path):
        return os.path.abspath(path)
    data = path
    ROOT_IMAGE_DIR = get_env_args('ROOT_IMAGE_DIR', str, None)
    if ROOT_IMAGE_DIR is not None:
        path = os.path.join(ROOT_IMAGE_DIR, path)
    path = os.path.abspath(os.path.expanduser(path))
    if os.path.exists(path):
        return path
    if data.startswith('data:'):
        return
    try:
        base64.b64decode(data)
        return
    except Exception:
        pass
    return data


def load_file(path: Union[str, bytes, _T]) -> Union[BytesIO, _T]:
    res = path
    if isinstance(path, str):
        path = path.strip()
        if path.startswith('http'):
            retries = Retry(total=3, backoff_factor=1, allowed_methods=['GET'])
            with requests.Session() as session:
                session.mount('http://', HTTPAdapter(max_retries=retries))
                session.mount('https://', HTTPAdapter(max_retries=retries))

                timeout = float(os.getenv('SWIFT_TIMEOUT', '20'))
                request_kwargs = {'timeout': timeout} if timeout > 0 else {}

                response = session.get(path, **request_kwargs)
                response.raise_for_status()
                content = response.content
                res = BytesIO(content)
        else:
            data = path
            path = _check_path(path)
            if path is None:
                # base64_str
                if data.startswith('data:'):
                    match_ = re.match(r'data:(.+?);base64,(.+)', data)
                    assert match_ is not None
                    data = match_.group(2)
                data = base64.b64decode(data)
                res = BytesIO(data)
            else:
                with open(path, 'rb') as f:
                    res = BytesIO(f.read())
    elif isinstance(path, bytes):
        res = BytesIO(path)
    return res


def load_image(image: Union[str, bytes, Image.Image]) -> Image.Image:
    image = load_file(image)
    if isinstance(image, BytesIO):
        image = Image.open(image)
    if image.mode != 'RGB':
        image = image.convert('RGB')
    return image


def load_batch(path_list: List[Union[str, None, Any, BytesIO]],
               load_func: Callable[[Any], _T] = load_image) -> List[_T]:
    res = []
    assert isinstance(path_list, (list, tuple)), f'path_list: {path_list}'
    for path in path_list:
        if path is None:  # ignore None
            continue
        res.append(load_func(path))
    return res


def load_video_hf(videos: List[str]):
    from transformers.video_utils import load_video
    res = []
    video_metadata = []
    for video in videos:
        if isinstance(video, (list, tuple)) and isinstance(video[0], str):
            # Case a: Video is provided as a list of image file names
            video = [np.array(load_image(image_fname)) for image_fname in video]
            video = np.stack(video)
            metadata = None
        else:
            # Case b: Video is provided as a single file path or URL or decoded frames in a np.ndarray or torch.tensor
            video_load_backend = get_env_args('video_load_backend', str, 'pyav')
            video, metadata = load_video(
                video,
                backend=video_load_backend,
            )
        res.append(video)
        video_metadata.append(metadata)
    return res, video_metadata


def _get_index(bound, fps, max_frame, first_idx=0, num_segments=32):
    if bound:
        start, end = bound[0], bound[1]
    else:
        start, end = -100000, 100000
    start_idx = max(first_idx, round(start * fps))
    end_idx = min(round(end * fps), max_frame)
    seg_size = float(end_idx - start_idx) / num_segments
    frame_indices = np.array(
        [int(start_idx + (seg_size / 2) + np.round(seg_size * idx)) for idx in range(num_segments)])
    return frame_indices


def transform_image(image, input_size=448, max_num=12):
    transform = _build_transform(input_size=input_size)
    images = _dynamic_preprocess(image, image_size=input_size, use_thumbnail=True, max_num=max_num)
    pixel_values = [transform(image) for image in images]
    pixel_values = torch.stack(pixel_values)
    return pixel_values


def load_video_internvl(video: Union[str, bytes], bound=None, num_segments=32):
    from decord import VideoReader, cpu
    video_io = load_file(video)
    vr = VideoReader(video_io, ctx=cpu(0), num_threads=1)
    max_frame = len(vr) - 1
    fps = float(vr.get_avg_fps())

    images = []
    frame_indices = _get_index(bound, fps, max_frame, first_idx=0, num_segments=num_segments)
    for frame_index in frame_indices:
        images.append(Image.fromarray(vr[frame_index].asnumpy()).convert('RGB'))
    return images


def load_video_cogvlm2(video: Union[str, bytes]) -> np.ndarray:
    from decord import VideoReader, bridge, cpu
    video_io = load_file(video)
    bridge.set_bridge('torch')
    clip_end_sec = 60
    clip_start_sec = 0
    num_frames = get_env_args('num_frames', int, 24)
    decord_vr = VideoReader(video_io, ctx=cpu(0))
    duration = len(decord_vr)  # duration in terms of frames
    start_frame = int(clip_start_sec * decord_vr.get_avg_fps())
    end_frame = min(duration, int(clip_end_sec * decord_vr.get_avg_fps())) if \
        clip_end_sec is not None else duration
    frame_id_list = np.linspace(start_frame, end_frame - 1, num_frames, dtype=int)
    video_data = decord_vr.get_batch(frame_id_list)
    video_data = video_data.permute(3, 0, 1, 2)
    return video_data


def load_video_llava(video: Union[str, bytes]) -> np.ndarray:
    import av
    video_io = load_file(video)
    container = av.open(video_io)
    total_frames = container.streams.video[0].frames
    num_frames = get_env_args('num_frames', int, 16)
    indices = np.linspace(0, total_frames - 1, num_frames, dtype=int)
    frames = []
    container.seek(0)
    start_index = indices[0]
    end_index = indices[-1]
    for i, frame in enumerate(container.decode(video=0)):
        if i > end_index:
            break
        if i >= start_index and i in indices:
            frames.append(frame)
    return np.stack([x.to_ndarray(format='rgb24') for x in frames])


def load_video_minicpmv_mplug_owl3(video: Union[str, bytes], max_num_frames):

    from decord import VideoReader, cpu  # pip install decord

    def uniform_sample(_l, _n):
        gap = len(_l) / _n
        idxs = [int(i * gap + gap / 2) for i in range(_n)]
        return [_l[i] for i in idxs]

    video_io = load_file(video)
    vr = VideoReader(video_io, ctx=cpu(0))
    sample_fps = round(vr.get_avg_fps() / 1)  # FPS
    frame_idx = [i for i in range(0, len(vr), sample_fps)]

    if len(frame_idx) > max_num_frames:
        frame_idx = uniform_sample(frame_idx, max_num_frames)
    frames = vr.get_batch(frame_idx).asnumpy()
    frames = [Image.fromarray(v.astype('uint8')) for v in frames]
    return frames


def _load_audio_librosa(audio: Union[str, bytes], sampling_rate: int, mono: bool = True):
    import librosa
    try:
        audio_io = load_file(audio)
        return librosa.load(audio_io, sr=sampling_rate, mono=mono)
    except Exception:
        if isinstance(audio, str) and audio.startswith(('http://', 'https://')):
            import audioread
            audio_io = audioread.ffdec.FFmpegAudioFile(audio)
        else:
            audio_io = _check_path(audio) if isinstance(audio, str) else audio
        return librosa.load(audio_io, sr=sampling_rate, mono=mono)


# ref: https://github.com/vllm-project/vllm/blob/v0.23.0/vllm/multimodal/audio.py#L169-L224
def _resample_audio_pyav(audio: np.ndarray, *, orig_sr: float, target_sr: float) -> np.ndarray:
    import av
    orig_sr_int = int(round(orig_sr))
    target_sr_int = int(round(target_sr))
    if orig_sr_int == target_sr_int:
        return audio
    if audio.ndim == 2:
        return np.stack([_resample_audio_pyav(ch, orig_sr=orig_sr, target_sr=target_sr) for ch in audio], axis=0)
    expected_len = int(math.ceil(audio.shape[-1] * target_sr_int / orig_sr_int))
    min_samples = 1024
    audio_f32 = np.asarray(audio, dtype=np.float32)
    if len(audio_f32) < min_samples:
        audio_f32 = np.pad(audio_f32, (0, min_samples - len(audio_f32)))
    audio_f32 = audio_f32.reshape(1, -1)
    resampler = av.AudioResampler(format='fltp', layout='mono', rate=target_sr_int)
    frame = av.AudioFrame.from_ndarray(audio_f32, format='fltp', layout='mono')
    frame.sample_rate = orig_sr_int
    out_frames = resampler.resample(frame)
    out_frames.extend(resampler.resample(None))
    result = np.concatenate([f.to_ndarray() for f in out_frames], axis=1).squeeze(0)
    return result[:expected_len]


# ref: https://github.com/vllm-project/vllm/blob/v0.23.0/vllm/multimodal/media/audio.py#L45-L160
def _load_audio_soundfile_pyav(path: Union[str, bytes, BytesIO], *, sr: float, mono: bool = True):
    """soundfile first, pyav fallback — same strategy as vLLM multimodal audio loader."""
    bad_sf_codes = {0, 1, 3, 4}
    if not isinstance(path, BytesIO):
        path = load_file(path)

    def _load_soundfile():
        import soundfile
        with soundfile.SoundFile(path) as f:
            native_sr = f.samplerate
            y = f.read(dtype='float32', always_2d=False).T

        if mono and y.ndim > 1:
            y = np.mean(y, axis=tuple(range(y.ndim - 1)))

        if sr is not None and sr != native_sr:
            y = _resample_audio_pyav(y, orig_sr=native_sr, target_sr=sr)
            return y, int(sr)
        return y, native_sr

    def _load_pyav():
        import av
        path.seek(0)
        with av.open(path) as container:
            if not container.streams.audio:
                raise ValueError('No audio stream found.')
            stream = container.streams.audio[0]
            stream.thread_type = 'AUTO'
            native_sr = stream.rate
            target_sr = sr or native_sr
            chunks = []
            needs_resampling = not math.isclose(float(target_sr), float(native_sr), rel_tol=0.0, abs_tol=1e-6)
            resampler = av.AudioResampler(format='fltp', layout='mono', rate=target_sr) if needs_resampling else None
            for frame in container.decode(stream):
                if needs_resampling:
                    for out_frame in resampler.resample(frame):
                        chunks.append(out_frame.to_ndarray())
                else:
                    chunks.append(frame.to_ndarray())
        if not chunks:
            raise ValueError('No audio found.')
        y = np.concatenate(chunks, axis=-1).astype(np.float32)
        if mono and y.ndim > 1:
            y = np.mean(y, axis=0)
        return y, target_sr

    try:
        return _load_soundfile()
    except ImportError:
        path.seek(0)
        return _load_pyav()
    except Exception as exc:
        import soundfile
        if not isinstance(exc, soundfile.LibsndfileError) or exc.code not in bad_sf_codes:
            raise
    path.seek(0)
    return _load_pyav()


def load_audio(
    audio: Union[str, bytes, BytesIO],
    sampling_rate: int,
    return_sr: bool = False,
    mono: bool = True,
):
    backend = get_env_args('swift_audio_load_backend', str, 'librosa')
    if backend == 'librosa':
        res = _load_audio_librosa(audio, sampling_rate, mono=mono)
    elif backend == 'soundfile_pyav':
        res = _load_audio_soundfile_pyav(audio, sr=sampling_rate, mono=mono)
    else:
        raise ValueError(f'Unknown audio load backend {backend!r}. Supported: librosa, soundfile_pyav')
    return res if return_sr else res[0]


def _resolve_video_local_path(path: Union[str, bytes]) -> tuple:
    """Return (local_path, is_temp_file). HTTP URLs and raw bytes are written to a temp file."""
    if isinstance(path, bytes) or (isinstance(path, str) and path.startswith('http')):
        import tempfile
        with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as f:
            f.write(load_file(path).read())
            return f.name, True
    checked = _check_path(path) if isinstance(path, str) else None
    return checked or path, False


def _video_to_ndarrays_local(local_path: str, num_frames: int = -1) -> np.ndarray:
    import cv2
    cap = cv2.VideoCapture(local_path)
    if not cap.isOpened():
        raise ValueError(f'Could not open video file {local_path}')
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    if total_frames <= 0:
        cap.release()
        raise ValueError(f'Video file {local_path} has invalid or zero frame count: {total_frames}')
    if num_frames <= 0 or num_frames > total_frames:
        num_frames = total_frames
    frame_indices = set(np.linspace(0, total_frames - 1, num_frames, dtype=int))
    frames = []
    for idx in range(total_frames):
        if not cap.grab():
            break
        if idx in frame_indices:
            ret, frame = cap.retrieve()
            if ret:
                frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
    cap.release()
    if len(frames) < num_frames:
        raise ValueError(f'Could not read enough frames from video file {local_path} '
                         f'(expected {num_frames} frames, got {len(frames)})')
    return np.stack(frames)


def _video_get_metadata_local(local_path: str, num_frames: int = -1) -> dict:
    import cv2
    cap = cv2.VideoCapture(local_path)
    if not cap.isOpened():
        raise ValueError(f'Could not open video file {local_path}')
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    if total_frames <= 0:
        cap.release()
        raise ValueError(f'Video file {local_path} has invalid or zero frame count: {total_frames}')
    fps = cap.get(cv2.CAP_PROP_FPS)
    duration = total_frames / fps if fps > 0 else 0
    cap.release()
    if num_frames <= 0 or num_frames > total_frames:
        num_frames = total_frames
    return {
        'total_num_frames': num_frames,
        'fps': duration / num_frames if num_frames else fps,
        'duration': duration,
        'video_backend': 'opencv',
        'frames_indices': list(range(num_frames)),
        'do_sample_frames': num_frames == total_frames,
    }


def load_vllm_video(path: Union[str, bytes], num_frames: int = -1) -> tuple:
    """Decode video frames + metadata for vLLM rollout; one download, temp file cleaned up."""
    local_path, is_temp = _resolve_video_local_path(path)
    try:
        return _video_to_ndarrays_local(local_path, num_frames), _video_get_metadata_local(local_path, num_frames)
    finally:
        if is_temp:
            try:
                os.remove(local_path)
            except OSError:
                pass


def load_video_valley(video: Union[str, bytes]):
    import decord
    from torchvision import transforms
    video_io = load_file(video)
    video_reader = decord.VideoReader(video_io)
    decord.bridge.set_bridge('torch')
    video = video_reader.get_batch(np.linspace(0, len(video_reader) - 1, 8).astype(np.int_)).byte()
    images = [transforms.ToPILImage()(image.permute(2, 0, 1)).convert('RGB') for image in video]
    return images


def load_video_ovis2(video_path, num_frames):
    from moviepy.editor import VideoFileClip
    with VideoFileClip(video_path) as clip:
        total_frames = int(clip.fps * clip.duration)
        if total_frames <= num_frames:
            sampled_indices = range(total_frames)
        else:
            stride = total_frames / num_frames
            sampled_indices = [
                min(total_frames - 1, int((stride * i + stride * (i + 1)) / 2)) for i in range(num_frames)
            ]
        frames = [clip.get_frame(index / clip.fps) for index in sampled_indices]
        frames = [Image.fromarray(frame, mode='RGB') for frame in frames]
    return frames


def load_video_ovis2_5(video_path, num_frames):
    from moviepy.editor import VideoFileClip
    with VideoFileClip(video_path) as clip:
        total_frames = int(clip.fps * clip.duration)
        indices = [int(i * total_frames / num_frames) for i in range(num_frames)]
        frames = [Image.fromarray(clip.get_frame(t)) for t in (idx / clip.fps for idx in indices)]
    return frames
