"""
media-mcp: MCP server for image & video processing.

Provides tools for resizing, converting, compressing, cropping images,
extracting metadata, creating thumbnails, and basic video operations.
No API keys. No config. Just tools.
"""

from __future__ import annotations

import base64
import io
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional

from fastmcp import FastMCP

try:
    from PIL import Image, ImageFilter, ImageOps
    from PIL.ExifTags import TAGS
except ImportError:
    raise ImportError(
        "Pillow is required: pip install media-mcp"
    )

mcp = FastMCP(
    "media-mcp",
    instructions=(
        "Media processing MCP server. Provides tools for image and video "
        "manipulation including resize, convert, compress, crop, metadata "
        "extraction, thumbnail creation, and more. All file paths should be "
        "absolute paths on the local filesystem."
    ),
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

SUPPORTED_IMAGE_FORMATS = {
    "png", "jpeg", "jpg", "webp", "gif", "bmp", "tiff", "ico", "avif"
}

FORMAT_MAP = {
    "jpg": "JPEG",
    "jpeg": "JPEG",
    "png": "PNG",
    "webp": "WEBP",
    "gif": "GIF",
    "bmp": "BMP",
    "tiff": "TIFF",
    "ico": "ICO",
    "avif": "AVIF",
}


def _resolve_path(path: str) -> Path:
    """Resolve and validate a file path."""
    p = Path(path).expanduser().resolve()
    if not p.exists():
        raise FileNotFoundError(f"File not found: {p}")
    return p


def _output_path(input_path: Path, suffix: str = "", new_ext: str | None = None) -> Path:
    """Generate an output path next to the input file."""
    ext = new_ext or input_path.suffix
    if not ext.startswith("."):
        ext = f".{ext}"
    name = input_path.stem + suffix + ext
    return input_path.parent / name


def _pil_format(fmt: str) -> str:
    """Convert user-facing format string to PIL format name."""
    return FORMAT_MAP.get(fmt.lower(), fmt.upper())


def _get_exif(img: Image.Image) -> dict:
    """Extract EXIF data as a JSON-safe dict."""
    exif_data = img.getexif()
    if not exif_data:
        return {}
    result = {}
    for tag_id, value in exif_data.items():
        tag = TAGS.get(tag_id, tag_id)
        # Convert bytes to string for JSON serialization
        if isinstance(value, bytes):
            try:
                value = value.decode("utf-8", errors="replace")
            except Exception:
                value = str(value)
        result[str(tag)] = str(value)
    return result


def _has_ffmpeg() -> bool:
    """Check if ffmpeg is available on PATH."""
    return shutil.which("ffmpeg") is not None


def _has_ffprobe() -> bool:
    """Check if ffprobe is available on PATH."""
    return shutil.which("ffprobe") is not None


# ---------------------------------------------------------------------------
# Image Tools
# ---------------------------------------------------------------------------


@mcp.tool
def get_image_info(path: str) -> dict:
    """Get detailed information about an image file.

    Returns dimensions, format, color mode, file size, and EXIF metadata.

    Args:
        path: Absolute path to the image file.
    """
    p = _resolve_path(path)
    file_size = p.stat().st_size

    with Image.open(p) as img:
        return {
            "path": str(p),
            "format": img.format,
            "mode": img.mode,
            "width": img.width,
            "height": img.height,
            "file_size_bytes": file_size,
            "file_size_human": _human_size(file_size),
            "is_animated": getattr(img, "is_animated", False),
            "n_frames": getattr(img, "n_frames", 1),
            "exif": _get_exif(img),
        }


@mcp.tool
def resize_image(
    path: str,
    width: Optional[int] = None,
    height: Optional[int] = None,
    scale: Optional[float] = None,
    keep_aspect_ratio: bool = True,
    output_path: Optional[str] = None,
) -> dict:
    """Resize an image by dimensions or scale factor.

    Provide width/height, or a scale factor (e.g. 0.5 for half size).
    By default, aspect ratio is preserved. If only width or height is given,
    the other dimension is calculated automatically.

    Args:
        path: Absolute path to the image file.
        width: Target width in pixels.
        height: Target height in pixels.
        scale: Scale factor (e.g. 0.5 = half, 2.0 = double).
        keep_aspect_ratio: Preserve aspect ratio when both width and height given.
        output_path: Where to save. Defaults to <name>_resized.<ext> next to input.
    """
    p = _resolve_path(path)
    with Image.open(p) as img:
        orig_w, orig_h = img.size

        if scale is not None:
            new_w = int(orig_w * scale)
            new_h = int(orig_h * scale)
        elif width and height:
            if keep_aspect_ratio:
                img_copy = img.copy()
                img_copy.thumbnail((width, height), Image.LANCZOS)
                new_w, new_h = img_copy.size
            else:
                new_w, new_h = width, height
        elif width:
            ratio = width / orig_w
            new_w = width
            new_h = int(orig_h * ratio)
        elif height:
            ratio = height / orig_h
            new_w = int(orig_w * ratio)
            new_h = height
        else:
            raise ValueError("Provide width, height, or scale.")

        resized = img.resize((new_w, new_h), Image.LANCZOS)

        out = Path(output_path) if output_path else _output_path(p, "_resized")
        fmt = _pil_format(out.suffix.lstrip(".")) if out.suffix else img.format
        _save_image(resized, out, fmt, img)

        return {
            "output_path": str(out),
            "original_size": f"{orig_w}x{orig_h}",
            "new_size": f"{new_w}x{new_h}",
            "file_size_bytes": out.stat().st_size,
        }


@mcp.tool
def convert_image(
    path: str,
    format: str,
    output_path: Optional[str] = None,
    quality: int = 85,
) -> dict:
    """Convert an image to a different format.

    Supported formats: PNG, JPEG, WebP, GIF, BMP, TIFF, ICO, AVIF.

    Args:
        path: Absolute path to the image file.
        format: Target format (e.g. 'png', 'webp', 'jpeg').
        output_path: Where to save. Defaults to <name>.<new_ext> next to input.
        quality: Quality for lossy formats (1-100). Default 85.
    """
    fmt_lower = format.lower().strip(".")
    if fmt_lower not in SUPPORTED_IMAGE_FORMATS:
        raise ValueError(
            f"Unsupported format '{format}'. Supported: {', '.join(sorted(SUPPORTED_IMAGE_FORMATS))}"
        )

    p = _resolve_path(path)
    with Image.open(p) as img:
        # Handle RGBA -> JPEG (no alpha channel support)
        if fmt_lower in ("jpeg", "jpg") and img.mode in ("RGBA", "P"):
            img = img.convert("RGB")

        out = Path(output_path) if output_path else _output_path(p, "", new_ext=fmt_lower)
        pil_fmt = _pil_format(fmt_lower)

        save_kwargs = {}
        if pil_fmt in ("JPEG", "WEBP", "AVIF"):
            save_kwargs["quality"] = quality

        img.save(out, format=pil_fmt, **save_kwargs)

        return {
            "output_path": str(out),
            "format": pil_fmt,
            "file_size_bytes": out.stat().st_size,
            "file_size_human": _human_size(out.stat().st_size),
        }


@mcp.tool
def compress_image(
    path: str,
    quality: int = 60,
    max_width: Optional[int] = None,
    max_height: Optional[int] = None,
    output_path: Optional[str] = None,
) -> dict:
    """Compress an image to reduce file size.

    Optionally resize to max dimensions while compressing. Works best with
    JPEG and WebP. PNG files will be converted to optimized PNG.

    Args:
        path: Absolute path to the image file.
        quality: Compression quality (1-100, lower = smaller file). Default 60.
        max_width: Optional maximum width. Image will be downscaled if wider.
        max_height: Optional maximum height. Image will be downscaled if taller.
        output_path: Where to save. Defaults to <name>_compressed.<ext>.
    """
    p = _resolve_path(path)
    with Image.open(p) as img:
        original_size = p.stat().st_size

        # Downscale if needed
        if max_width or max_height:
            mw = max_width or img.width
            mh = max_height or img.height
            img.thumbnail((mw, mh), Image.LANCZOS)

        out = Path(output_path) if output_path else _output_path(p, "_compressed")
        fmt = _pil_format(out.suffix.lstrip(".")) if out.suffix else (img.format or "JPEG")

        save_kwargs = {}
        if fmt == "PNG":
            save_kwargs["optimize"] = True
        elif fmt in ("JPEG", "WEBP", "AVIF"):
            save_kwargs["quality"] = quality
            save_kwargs["optimize"] = True

        # Handle RGBA for JPEG
        if fmt == "JPEG" and img.mode in ("RGBA", "P"):
            img = img.convert("RGB")

        img.save(out, format=fmt, **save_kwargs)
        new_size = out.stat().st_size

        return {
            "output_path": str(out),
            "original_size_bytes": original_size,
            "compressed_size_bytes": new_size,
            "reduction_percent": round((1 - new_size / original_size) * 100, 1) if original_size > 0 else 0,
            "dimensions": f"{img.width}x{img.height}",
        }


@mcp.tool
def crop_image(
    path: str,
    left: int,
    top: int,
    right: int,
    bottom: int,
    output_path: Optional[str] = None,
) -> dict:
    """Crop an image to a specific region.

    Coordinates are in pixels from the top-left corner.

    Args:
        path: Absolute path to the image file.
        left: Left edge x coordinate.
        top: Top edge y coordinate.
        right: Right edge x coordinate.
        bottom: Bottom edge y coordinate.
        output_path: Where to save. Defaults to <name>_cropped.<ext>.
    """
    p = _resolve_path(path)
    with Image.open(p) as img:
        if left < 0 or top < 0 or right > img.width or bottom > img.height:
            raise ValueError(
                f"Crop region ({left},{top},{right},{bottom}) is outside image bounds ({img.width}x{img.height})"
            )
        if left >= right or top >= bottom:
            raise ValueError("Invalid crop region: left must be < right and top must be < bottom")

        cropped = img.crop((left, top, right, bottom))
        out = Path(output_path) if output_path else _output_path(p, "_cropped")
        fmt = _pil_format(out.suffix.lstrip(".")) if out.suffix else img.format
        _save_image(cropped, out, fmt, img)

        return {
            "output_path": str(out),
            "original_size": f"{img.width}x{img.height}",
            "cropped_size": f"{cropped.width}x{cropped.height}",
        }


@mcp.tool
def create_thumbnail(
    path: str,
    size: int = 256,
    output_path: Optional[str] = None,
) -> dict:
    """Create a square thumbnail from an image.

    The image is resized to fit within a square of the given size,
    preserving aspect ratio.

    Args:
        path: Absolute path to the image file.
        size: Thumbnail size in pixels (default 256). Creates a size x size bounding box.
        output_path: Where to save. Defaults to <name>_thumb.<ext>.
    """
    p = _resolve_path(path)
    with Image.open(p) as img:
        thumb = img.copy()
        thumb.thumbnail((size, size), Image.LANCZOS)
        out = Path(output_path) if output_path else _output_path(p, "_thumb")
        fmt = _pil_format(out.suffix.lstrip(".")) if out.suffix else img.format
        _save_image(thumb, out, fmt, img)

        return {
            "output_path": str(out),
            "thumbnail_size": f"{thumb.width}x{thumb.height}",
        }


@mcp.tool
def strip_metadata(
    path: str,
    output_path: Optional[str] = None,
) -> dict:
    """Remove all EXIF and metadata from an image for privacy.

    Creates a clean copy of the image with no embedded metadata.

    Args:
        path: Absolute path to the image file.
        output_path: Where to save. Defaults to <name>_clean.<ext>.
    """
    p = _resolve_path(path)
    with Image.open(p) as img:
        # Create a fresh image without metadata
        clean = Image.new(img.mode, img.size)
        if hasattr(img, "get_flattened_data"):
            clean.putdata(list(img.get_flattened_data()))
        else:
            clean.putdata(list(img.getdata()))

        out = Path(output_path) if output_path else _output_path(p, "_clean")
        fmt = _pil_format(out.suffix.lstrip(".")) if out.suffix else img.format

        save_kwargs = {}
        if fmt in ("JPEG", "WEBP"):
            save_kwargs["quality"] = 95

        if fmt == "JPEG" and clean.mode in ("RGBA", "P"):
            clean = clean.convert("RGB")

        clean.save(out, format=fmt, **save_kwargs)

        return {
            "output_path": str(out),
            "metadata_removed": True,
        }


@mcp.tool
def rotate_image(
    path: str,
    degrees: float,
    expand: bool = True,
    output_path: Optional[str] = None,
) -> dict:
    """Rotate an image by a specified number of degrees.

    Args:
        path: Absolute path to the image file.
        degrees: Rotation angle in degrees (counter-clockwise).
        expand: If True, expand the output to fit the full rotated image.
        output_path: Where to save. Defaults to <name>_rotated.<ext>.
    """
    p = _resolve_path(path)
    with Image.open(p) as img:
        rotated = img.rotate(degrees, expand=expand, resample=Image.LANCZOS)
        out = Path(output_path) if output_path else _output_path(p, "_rotated")
        fmt = _pil_format(out.suffix.lstrip(".")) if out.suffix else img.format
        _save_image(rotated, out, fmt, img)

        return {
            "output_path": str(out),
            "rotation_degrees": degrees,
            "new_size": f"{rotated.width}x{rotated.height}",
        }


@mcp.tool
def flip_image(
    path: str,
    direction: str = "horizontal",
    output_path: Optional[str] = None,
) -> dict:
    """Flip (mirror) an image horizontally or vertically.

    Args:
        path: Absolute path to the image file.
        direction: 'horizontal' or 'vertical'.
        output_path: Where to save. Defaults to <name>_flipped.<ext>.
    """
    p = _resolve_path(path)
    with Image.open(p) as img:
        if direction == "horizontal":
            flipped = ImageOps.mirror(img)
        elif direction == "vertical":
            flipped = ImageOps.flip(img)
        else:
            raise ValueError("direction must be 'horizontal' or 'vertical'")

        out = Path(output_path) if output_path else _output_path(p, "_flipped")
        fmt = _pil_format(out.suffix.lstrip(".")) if out.suffix else img.format
        _save_image(flipped, out, fmt, img)

        return {
            "output_path": str(out),
            "direction": direction,
        }


@mcp.tool
def apply_filter(
    path: str,
    filter_name: str,
    output_path: Optional[str] = None,
) -> dict:
    """Apply a filter to an image.

    Available filters: blur, sharpen, smooth, detail, contour, edge_enhance,
    emboss, grayscale.

    Args:
        path: Absolute path to the image file.
        filter_name: Name of the filter to apply.
        output_path: Where to save. Defaults to <name>_<filter>.<ext>.
    """
    FILTERS = {
        "blur": ImageFilter.BLUR,
        "sharpen": ImageFilter.SHARPEN,
        "smooth": ImageFilter.SMOOTH,
        "detail": ImageFilter.DETAIL,
        "contour": ImageFilter.CONTOUR,
        "edge_enhance": ImageFilter.EDGE_ENHANCE,
        "emboss": ImageFilter.EMBOSS,
    }

    p = _resolve_path(path)
    filter_lower = filter_name.lower()

    with Image.open(p) as img:
        if filter_lower == "grayscale":
            filtered = ImageOps.grayscale(img)
        elif filter_lower in FILTERS:
            filtered = img.filter(FILTERS[filter_lower])
        else:
            available = sorted(list(FILTERS.keys()) + ["grayscale"])
            raise ValueError(
                f"Unknown filter '{filter_name}'. Available: {', '.join(available)}"
            )

        out = Path(output_path) if output_path else _output_path(p, f"_{filter_lower}")
        fmt = _pil_format(out.suffix.lstrip(".")) if out.suffix else img.format
        _save_image(filtered, out, fmt, img)

        return {
            "output_path": str(out),
            "filter_applied": filter_name,
        }


# ---------------------------------------------------------------------------
# Video Tools (require ffmpeg)
# ---------------------------------------------------------------------------


@mcp.tool
def get_video_info(path: str) -> dict:
    """Get detailed information about a video file.

    Returns duration, resolution, codec, bitrate, fps, and audio info.
    Requires ffprobe (part of ffmpeg) to be installed.

    Args:
        path: Absolute path to the video file.
    """
    if not _has_ffprobe():
        raise RuntimeError(
            "ffprobe not found. Install ffmpeg: https://ffmpeg.org/download.html"
        )

    p = _resolve_path(path)
    cmd = [
        "ffprobe", "-v", "quiet",
        "-print_format", "json",
        "-show_format", "-show_streams",
        str(p),
    ]
    result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
    if result.returncode != 0:
        raise RuntimeError(f"ffprobe failed: {result.stderr}")

    probe = json.loads(result.stdout)
    fmt = probe.get("format", {})
    video_stream = next(
        (s for s in probe.get("streams", []) if s.get("codec_type") == "video"),
        None,
    )
    audio_stream = next(
        (s for s in probe.get("streams", []) if s.get("codec_type") == "audio"),
        None,
    )

    info: dict = {
        "path": str(p),
        "duration_seconds": float(fmt.get("duration", 0)),
        "file_size_bytes": int(fmt.get("size", 0)),
        "file_size_human": _human_size(int(fmt.get("size", 0))),
        "bit_rate": int(fmt.get("bit_rate", 0)),
        "format_name": fmt.get("format_name"),
    }

    if video_stream:
        fps_parts = video_stream.get("r_frame_rate", "0/1").split("/")
        fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 and float(fps_parts[1]) != 0 else 0
        info["video"] = {
            "codec": video_stream.get("codec_name"),
            "width": video_stream.get("width"),
            "height": video_stream.get("height"),
            "fps": round(fps, 2),
        }

    if audio_stream:
        info["audio"] = {
            "codec": audio_stream.get("codec_name"),
            "sample_rate": audio_stream.get("sample_rate"),
            "channels": audio_stream.get("channels"),
        }

    return info


@mcp.tool
def extract_frames(
    path: str,
    interval_seconds: float = 1.0,
    max_frames: int = 10,
    output_dir: Optional[str] = None,
    format: str = "png",
) -> dict:
    """Extract frames from a video at regular intervals.

    Args:
        path: Absolute path to the video file.
        interval_seconds: Time between extracted frames (default 1 second).
        max_frames: Maximum number of frames to extract (default 10).
        output_dir: Directory to save frames. Defaults to a 'frames' subdir next to the video.
        format: Image format for frames ('png' or 'jpg'). Default 'png'.
    """
    if not _has_ffmpeg():
        raise RuntimeError(
            "ffmpeg not found. Install ffmpeg: https://ffmpeg.org/download.html"
        )

    p = _resolve_path(path)
    out_dir = Path(output_dir) if output_dir else p.parent / f"{p.stem}_frames"
    out_dir.mkdir(parents=True, exist_ok=True)

    fmt = format.lower()
    if fmt not in ("png", "jpg", "jpeg"):
        raise ValueError("Frame format must be 'png' or 'jpg'")

    ext = "jpg" if fmt in ("jpg", "jpeg") else "png"
    pattern = str(out_dir / f"frame_%04d.{ext}")

    cmd = [
        "ffmpeg", "-i", str(p),
        "-vf", f"fps=1/{interval_seconds}",
        "-frames:v", str(max_frames),
        "-y",
        pattern,
    ]

    result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg failed: {result.stderr}")

    frames = sorted(out_dir.glob(f"frame_*.{ext}"))
    return {
        "output_dir": str(out_dir),
        "frames_extracted": len(frames),
        "frame_paths": [str(f) for f in frames],
    }


@mcp.tool
def convert_video(
    path: str,
    format: str,
    output_path: Optional[str] = None,
    resolution: Optional[str] = None,
    crf: int = 23,
) -> dict:
    """Convert a video to a different format or resolution.

    Supported output formats: mp4, webm, mov, avi, gif, mkv.

    Args:
        path: Absolute path to the video file.
        format: Target format (e.g. 'mp4', 'webm', 'gif').
        output_path: Where to save. Defaults to <name>.<new_ext>.
        resolution: Optional target resolution (e.g. '1280x720', '1920x1080').
        crf: Constant Rate Factor for quality (0-51, lower = better). Default 23.
    """
    if not _has_ffmpeg():
        raise RuntimeError(
            "ffmpeg not found. Install ffmpeg: https://ffmpeg.org/download.html"
        )

    supported = {"mp4", "webm", "mov", "avi", "gif", "mkv"}
    fmt = format.lower().strip(".")
    if fmt not in supported:
        raise ValueError(f"Unsupported format '{format}'. Supported: {', '.join(sorted(supported))}")

    p = _resolve_path(path)
    out = Path(output_path) if output_path else _output_path(p, "", new_ext=fmt)

    cmd = ["ffmpeg", "-i", str(p)]

    if resolution:
        cmd.extend(["-vf", f"scale={resolution.replace('x', ':')}"])

    if fmt in ("mp4", "mkv", "mov"):
        cmd.extend(["-c:v", "libx264", "-crf", str(crf), "-c:a", "aac"])
    elif fmt == "webm":
        cmd.extend(["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0", "-c:a", "libopus"])
    elif fmt == "gif":
        cmd.extend(["-vf", f"fps=10,scale=480:-1:flags=lanczos"])

    cmd.extend(["-y", str(out)])

    result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg failed: {result.stderr}")

    return {
        "output_path": str(out),
        "format": fmt,
        "file_size_bytes": out.stat().st_size,
        "file_size_human": _human_size(out.stat().st_size),
    }


# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------


def _human_size(size_bytes: int) -> str:
    """Convert bytes to human-readable string."""
    for unit in ("B", "KB", "MB", "GB"):
        if size_bytes < 1024:
            return f"{size_bytes:.1f} {unit}"
        size_bytes /= 1024
    return f"{size_bytes:.1f} TB"


def _save_image(img: Image.Image, path: Path, fmt: str, original: Image.Image | None = None) -> None:
    """Save an image, handling format-specific quirks."""
    save_kwargs: dict = {}

    if fmt == "JPEG":
        if img.mode in ("RGBA", "P", "LA"):
            img = img.convert("RGB")
        save_kwargs["quality"] = 95
    elif fmt == "WEBP":
        save_kwargs["quality"] = 90
    elif fmt == "PNG":
        save_kwargs["optimize"] = True

    img.save(path, format=fmt, **save_kwargs)


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


def main():
    """Run the media-mcp server."""
    mcp.run()


if __name__ == "__main__":
    main()
