//! Collection of methods for images.

use std::{error::Error, fmt, str::FromStr, sync::Arc};

use gettextrs::gettext;
use gtk::{gdk, gio, graphene, gsk, prelude::*};
use matrix_sdk::{
    attachment::{BaseImageInfo, Thumbnail},
    media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings},
    Client,
};
use ruma::{
    api::client::media::get_content_thumbnail::v3::Method,
    events::{
        room::{
            avatar::ImageInfo as AvatarImageInfo, ImageInfo, MediaSource as CommonMediaSource,
            ThumbnailInfo,
        },
        sticker::StickerMediaSource,
    },
    OwnedMxcUri,
};
use tracing::{error, warn};

mod queue;

pub(crate) use queue::{ImageRequestPriority, IMAGE_QUEUE};

use super::{FrameDimensions, MediaFileError};
use crate::{components::AnimatedImagePaintable, spawn_tokio, utils::File, DISABLE_GLYCIN_SANDBOX};

/// The maximum dimensions of a thumbnail in the timeline.
pub const THUMBNAIL_MAX_DIMENSIONS: FrameDimensions = FrameDimensions {
    width: 600,
    height: 400,
};
/// The content type of SVG.
const SVG_CONTENT_TYPE: &str = "image/svg+xml";
/// The content type of WebP.
const WEBP_CONTENT_TYPE: &str = "image/webp";
/// The default WebP quality used for a generated thumbnail.
const WEBP_DEFAULT_QUALITY: f32 = 60.0;
/// The maximum file size threshold in bytes for requesting or generating a
/// thumbnail.
///
/// If the file size of the original image is larger than this, we assume it is
/// worth it to request or generate a thumbnail, even if its dimensions are
/// smaller than wanted. This is particularly helpful for some image formats
/// that can take up a lot of space.
///
/// This is 1MB.
const THUMBNAIL_MAX_FILESIZE_THRESHOLD: u32 = 1024 * 1024;
/// The size threshold in pixels for requesting or generating a thumbnail.
///
/// If the original image is larger than dimensions + threshold, we assume it is
/// worth it to request or generate a thumbnail.
const THUMBNAIL_DIMENSIONS_THRESHOLD: u32 = 200;

/// Get an image loader for the given file.
async fn image_loader(file: gio::File) -> Result<glycin::Image<'static>, glycin::ErrorCtx> {
    let mut loader = glycin::Loader::new(file);

    if DISABLE_GLYCIN_SANDBOX {
        loader.sandbox_selector(glycin::SandboxSelector::NotSandboxed);
    }

    spawn_tokio!(async move { loader.load().await })
        .await
        .unwrap()
}

/// Load the given file as an image into a `GdkPaintable`.
///
/// Set `request_dimensions` if the image will be shown at specific dimensions.
/// To show the image at its natural size, set it to `None`.
async fn load_image(
    file: File,
    request_dimensions: Option<FrameDimensions>,
) -> Result<Image, glycin::ErrorCtx> {
    let image_loader = image_loader(file.as_gfile()).await?;

    let frame_request = request_dimensions.map(|request| {
        let image_info = image_loader.info();

        let original_dimensions = FrameDimensions {
            width: image_info.width,
            height: image_info.height,
        };

        original_dimensions.to_image_loader_request(request)
    });

    spawn_tokio!(async move {
        let first_frame = if let Some(frame_request) = frame_request {
            image_loader.specific_frame(frame_request).await?
        } else {
            image_loader.next_frame().await?
        };
        Ok(Image {
            file,
            loader: image_loader.into(),
            first_frame: first_frame.into(),
        })
    })
    .await
    .expect("task was not aborted")
}

/// An image that was just loaded.
#[derive(Clone)]
pub struct Image {
    /// The file of the image.
    file: File,
    /// The image loader.
    loader: Arc<glycin::Image<'static>>,
    /// The first frame of the image.
    first_frame: Arc<glycin::Frame>,
}

impl fmt::Debug for Image {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Image").finish_non_exhaustive()
    }
}

impl From<Image> for gdk::Paintable {
    fn from(value: Image) -> Self {
        if value.first_frame.delay().is_some() {
            AnimatedImagePaintable::new(value.file, value.loader, value.first_frame).upcast()
        } else {
            value.first_frame.texture().upcast()
        }
    }
}

/// An API to load image information.
pub enum ImageInfoLoader {
    /// An image file.
    File(gio::File),
    /// A texture in memory.
    Texture(gdk::Texture),
}

impl ImageInfoLoader {
    /// Load the first frame for this source.
    ///
    /// We need to load the first frame of an image so that EXIF rotation is
    /// applied and we get the proper dimensions.
    async fn into_first_frame(self) -> Option<Frame> {
        match self {
            Self::File(file) => {
                let image_loader = image_loader(file).await.ok()?;
                let handle = spawn_tokio!(async move { image_loader.next_frame().await });
                Some(Frame::Glycin(handle.await.unwrap().ok()?))
            }
            Self::Texture(texture) => Some(Frame::Texture(texture)),
        }
    }

    /// Load the information for this image.
    pub async fn load_info(self) -> BaseImageInfo {
        self.into_first_frame()
            .await
            .and_then(|f| f.dimensions())
            .map_or_else(default_base_image_info, Into::into)
    }

    /// Load the information for this image and try to generate a thumbnail
    /// given the filesize of the original image.
    pub async fn load_info_and_thumbnail(
        self,
        filesize: Option<u32>,
        widget: &impl IsA<gtk::Widget>,
    ) -> (BaseImageInfo, Option<Thumbnail>) {
        let Some(frame) = self.into_first_frame().await else {
            return (default_base_image_info(), None);
        };

        let dimensions = frame.dimensions();
        let info = dimensions.map_or_else(default_base_image_info, Into::into);

        // Generate the same thumbnail dimensions as we will need in the timeline.
        let scale_factor = widget.scale_factor();
        let max_thumbnail_dimensions =
            FrameDimensions::thumbnail_max_dimensions(widget.scale_factor());

        if !filesize_is_too_big(filesize)
            && !dimensions.is_some_and(|d| d.needs_thumbnail(max_thumbnail_dimensions))
        {
            // It is not worth it to generate a thumbnail.
            return (info, None);
        }

        let Some(renderer) = widget
            .root()
            .and_downcast::<gtk::Window>()
            .and_then(|w| w.renderer())
        else {
            // We cannot generate a thumbnail.
            error!("Could not get GdkRenderer");
            return (info, None);
        };

        let thumbnail = frame.generate_thumbnail(scale_factor, &renderer);

        (info, thumbnail)
    }
}

impl From<gio::File> for ImageInfoLoader {
    fn from(value: gio::File) -> Self {
        Self::File(value)
    }
}

impl From<gdk::Texture> for ImageInfoLoader {
    fn from(value: gdk::Texture) -> Self {
        Self::Texture(value)
    }
}

/// A frame of an image.
#[derive(Debug, Clone)]
enum Frame {
    /// A frame loaded via glycin.
    Glycin(glycin::Frame),
    /// A texture in memory,
    Texture(gdk::Texture),
}

impl Frame {
    /// The dimensions of the frame.
    fn dimensions(&self) -> Option<FrameDimensions> {
        match self {
            Self::Glycin(frame) => Some(FrameDimensions {
                width: frame.width(),
                height: frame.height(),
            }),
            Self::Texture(texture) => FrameDimensions::with_texture(texture),
        }
    }

    /// Generate a thumbnail of this frame.
    fn generate_thumbnail(self, scale_factor: i32, renderer: &gsk::Renderer) -> Option<Thumbnail> {
        let texture = match self {
            Self::Glycin(frame) => frame.texture(),
            Self::Texture(texture) => texture,
        };

        let thumbnail = TextureThumbnailer(texture).generate_thumbnail(scale_factor, renderer);

        if thumbnail.is_none() {
            warn!("Could not generate thumbnail from GdkTexture");
        }

        thumbnail
    }
}

/// Extensions to `FrameDimensions` for computing thumbnail dimensions.
impl FrameDimensions {
    /// Get the maximum dimensions for a thumbnail with the given scale factor.
    pub fn thumbnail_max_dimensions(scale_factor: i32) -> Self {
        let scale_factor = scale_factor.try_into().unwrap_or(1);
        THUMBNAIL_MAX_DIMENSIONS.scale(scale_factor)
    }

    /// Construct a `FrameDimensions` for the given texture.
    fn with_texture(texture: &gdk::Texture) -> Option<Self> {
        Some(Self {
            width: texture.width().try_into().ok()?,
            height: texture.height().try_into().ok()?,
        })
    }

    /// Whether we should generate or request a thumbnail for these dimensions,
    /// given the wanted thumbnail dimensions.
    pub(super) fn needs_thumbnail(self, thumbnail_dimensions: FrameDimensions) -> bool {
        self.ge(thumbnail_dimensions.increase_by(THUMBNAIL_DIMENSIONS_THRESHOLD))
    }

    /// Downscale these dimensions to fit into the given maximum dimensions
    /// while preserving the aspect ratio.
    ///
    /// Returns `None` if these dimensions are smaller than the maximum
    /// dimensions.
    pub(super) fn downscale_for(self, max_dimensions: FrameDimensions) -> Option<Self> {
        if !self.ge(max_dimensions) {
            // We do not need to downscale.
            return None;
        }

        Some(self.scale_to_fit(max_dimensions, gtk::ContentFit::ScaleDown))
    }

    /// Convert these dimensions to a request for the image loader with the
    /// requested dimensions.
    fn to_image_loader_request(self, requested: Self) -> glycin::FrameRequest {
        let scaled = self.scale_to_fit(requested, gtk::ContentFit::Cover);
        glycin::FrameRequest::new().scale(scaled.width, scaled.height)
    }
}

impl From<FrameDimensions> for BaseImageInfo {
    fn from(value: FrameDimensions) -> Self {
        let FrameDimensions { width, height } = value;
        BaseImageInfo {
            height: Some(height.into()),
            width: Some(width.into()),
            ..default_base_image_info()
        }
    }
}

/// The default value for `BaseImageInfo`.
fn default_base_image_info() -> BaseImageInfo {
    BaseImageInfo {
        height: None,
        width: None,
        size: None,
        blurhash: None,
    }
}

/// A thumbnailer for a `GdkTexture`.
#[derive(Debug, Clone)]
pub(super) struct TextureThumbnailer(pub(super) gdk::Texture);

impl TextureThumbnailer {
    /// Downscale the texture if needed to fit into the given maximum thumbnail
    /// dimensions.
    ///
    /// Returns `None` if the dimensions of the texture are unknown.
    fn downscale_texture_if_needed(
        self,
        max_dimensions: FrameDimensions,
        renderer: &gsk::Renderer,
    ) -> Option<gdk::Texture> {
        let dimensions = FrameDimensions::with_texture(&self.0)?;

        let texture = if let Some(target_dimensions) = dimensions.downscale_for(max_dimensions) {
            let snapshot = gtk::Snapshot::new();
            let bounds = graphene::Rect::new(
                0.0,
                0.0,
                target_dimensions.width as f32,
                target_dimensions.height as f32,
            );
            snapshot.append_texture(&self.0, &bounds);
            let node = snapshot.to_node()?;
            renderer.render_texture(node, None)
        } else {
            self.0
        };

        Some(texture)
    }

    /// Convert the given texture memory format to the format needed to make a
    /// thumbnail.
    ///
    /// The WebP encoder only supports RGB and RGBA.
    ///
    /// Returns `None` if the format is unknown.
    fn texture_format_to_thumbnail_format(
        format: gdk::MemoryFormat,
    ) -> Option<(gdk::MemoryFormat, webp::PixelLayout)> {
        match format {
            gdk::MemoryFormat::B8g8r8a8Premultiplied
            | gdk::MemoryFormat::A8r8g8b8Premultiplied
            | gdk::MemoryFormat::R8g8b8a8Premultiplied
            | gdk::MemoryFormat::B8g8r8a8
            | gdk::MemoryFormat::A8r8g8b8
            | gdk::MemoryFormat::R8g8b8a8
            | gdk::MemoryFormat::R16g16b16a16Premultiplied
            | gdk::MemoryFormat::R16g16b16a16
            | gdk::MemoryFormat::R16g16b16a16FloatPremultiplied
            | gdk::MemoryFormat::R16g16b16a16Float
            | gdk::MemoryFormat::R32g32b32a32FloatPremultiplied
            | gdk::MemoryFormat::R32g32b32a32Float
            | gdk::MemoryFormat::G8a8Premultiplied
            | gdk::MemoryFormat::G8a8
            | gdk::MemoryFormat::G16a16Premultiplied
            | gdk::MemoryFormat::G16a16
            | gdk::MemoryFormat::A8
            | gdk::MemoryFormat::A16
            | gdk::MemoryFormat::A16Float
            | gdk::MemoryFormat::A32Float
            | gdk::MemoryFormat::A8b8g8r8Premultiplied
            | gdk::MemoryFormat::A8b8g8r8 => {
                Some((gdk::MemoryFormat::R8g8b8a8, webp::PixelLayout::Rgba))
            }
            gdk::MemoryFormat::R8g8b8
            | gdk::MemoryFormat::B8g8r8
            | gdk::MemoryFormat::R16g16b16
            | gdk::MemoryFormat::R16g16b16Float
            | gdk::MemoryFormat::R32g32b32Float
            | gdk::MemoryFormat::G8
            | gdk::MemoryFormat::G16
            | gdk::MemoryFormat::B8g8r8x8
            | gdk::MemoryFormat::X8r8g8b8
            | gdk::MemoryFormat::R8g8b8x8
            | gdk::MemoryFormat::X8b8g8r8 => {
                Some((gdk::MemoryFormat::R8g8b8, webp::PixelLayout::Rgb))
            }
            _ => None,
        }
    }

    /// Generate the thumbnail for the given scale factor, with the given
    /// `GskRenderer`.
    pub(super) fn generate_thumbnail(
        self,
        scale_factor: i32,
        renderer: &gsk::Renderer,
    ) -> Option<Thumbnail> {
        let max_thumbnail_dimensions = FrameDimensions::thumbnail_max_dimensions(scale_factor);
        let thumbnail = self.downscale_texture_if_needed(max_thumbnail_dimensions, renderer)?;
        let dimensions = FrameDimensions::with_texture(&thumbnail)?;

        let (downloader_format, webp_layout) =
            Self::texture_format_to_thumbnail_format(thumbnail.format())?;

        let mut downloader = gdk::TextureDownloader::new(&thumbnail);
        downloader.set_format(downloader_format);
        let (data, _) = downloader.download_bytes();

        let encoder = webp::Encoder::new(&data, webp_layout, dimensions.width, dimensions.height);
        let data = encoder.encode(WEBP_DEFAULT_QUALITY).to_vec();

        let size = data.len().try_into().ok()?;
        let content_type =
            mime::Mime::from_str(WEBP_CONTENT_TYPE).expect("content type should be valid");

        Some(Thumbnail {
            data,
            content_type,
            width: dimensions.width.into(),
            height: dimensions.height.into(),
            size,
        })
    }
}

/// An API to download a thumbnail for a media.
#[derive(Debug, Clone, Copy)]
pub struct ThumbnailDownloader<'a> {
    /// The main source of the image.
    ///
    /// This should be the source with the best quality.
    pub main: ImageSource<'a>,
    /// An alternative source for the image.
    ///
    /// This should be a source with a lower quality.
    pub alt: Option<ImageSource<'a>>,
}

impl<'a> ThumbnailDownloader<'a> {
    /// Download the thumbnail of the media.
    ///
    /// This might not return a thumbnail at the requested dimensions, depending
    /// on the sources and the homeserver.
    pub async fn download(
        self,
        client: Client,
        settings: ThumbnailSettings,
        priority: ImageRequestPriority,
    ) -> Result<Image, ImageError> {
        let dimensions = settings.dimensions;

        // First, select which source we are going to download from.
        let source = if let Some(alt) = self.alt {
            if !self.main.can_be_thumbnailed()
                && (filesize_is_too_big(self.main.filesize())
                    || alt.dimensions().is_some_and(|s| s.ge(settings.dimensions)))
            {
                // Use the alternative source to save bandwidth.
                alt
            } else {
                self.main
            }
        } else {
            self.main
        };

        if source.should_thumbnail(settings.prefer_thumbnail, settings.dimensions) {
            // Try to get a thumbnail.
            let request = MediaRequestParameters {
                source: source.source.to_common_media_source(),
                format: MediaFormat::Thumbnail(settings.into()),
            };
            let handle = IMAGE_QUEUE
                .add_download_request(client.clone(), request, Some(dimensions), priority)
                .await;

            if let Ok(image) = handle.await {
                return Ok(image);
            }
        }

        // Fallback to downloading the full source.
        let request = MediaRequestParameters {
            source: source.source.to_common_media_source(),
            format: MediaFormat::File,
        };
        let handle = IMAGE_QUEUE
            .add_download_request(client, request, Some(dimensions), priority)
            .await;

        handle.await
    }
}

/// The source of an image.
#[derive(Debug, Clone, Copy)]
pub struct ImageSource<'a> {
    /// The source of the image.
    pub source: MediaSource<'a>,
    /// Information about the image.
    pub info: Option<ImageSourceInfo<'a>>,
}

impl ImageSource<'_> {
    /// Whether we should try to thumbnail this source for the given requested
    /// dimensions.
    fn should_thumbnail(
        &self,
        prefer_thumbnail: bool,
        thumbnail_dimensions: FrameDimensions,
    ) -> bool {
        if !self.can_be_thumbnailed() {
            return false;
        }

        let dimensions = self.dimensions();

        if prefer_thumbnail && dimensions.is_none() {
            return true;
        }

        dimensions.is_some_and(|d| d.needs_thumbnail(thumbnail_dimensions))
            || filesize_is_too_big(self.filesize())
    }

    /// Whether this source can be thumbnailed by the media repo.
    ///
    /// Returns `false` in these cases:
    ///
    /// - The image is encrypted, because it is not possible for the media repo
    ///   to make a thumbnail.
    /// - The image uses the SVG format, because media repos usually do not
    ///   accept to create a thumbnail of those.
    fn can_be_thumbnailed(&self) -> bool {
        !self.source.is_encrypted()
            && self
                .info
                .and_then(|i| i.mimetype)
                .is_none_or(|m| m != SVG_CONTENT_TYPE)
    }

    /// The filesize of this source.
    fn filesize(&self) -> Option<u32> {
        self.info.and_then(|i| i.filesize)
    }

    /// The dimensions of this source.
    fn dimensions(&self) -> Option<FrameDimensions> {
        self.info.and_then(|i| i.dimensions)
    }
}

/// Whether the given filesize is considered too big to be the preferred source
/// to download.
fn filesize_is_too_big(filesize: Option<u32>) -> bool {
    filesize.is_some_and(|s| s > THUMBNAIL_MAX_FILESIZE_THRESHOLD)
}

/// The source of a media file.
#[derive(Debug, Clone, Copy)]
pub enum MediaSource<'a> {
    /// A common media source.
    Common(&'a CommonMediaSource),
    /// The media source of a sticker.
    Sticker(&'a StickerMediaSource),
    /// An MXC URI.
    Uri(&'a OwnedMxcUri),
}

impl MediaSource<'_> {
    /// Whether this source is encrypted.
    fn is_encrypted(&self) -> bool {
        match self {
            Self::Common(source) => matches!(source, CommonMediaSource::Encrypted(_)),
            Self::Sticker(source) => matches!(source, StickerMediaSource::Encrypted(_)),
            Self::Uri(_) => false,
        }
    }

    /// Get this source as a `CommonMediaSource`.
    fn to_common_media_source(self) -> CommonMediaSource {
        match self {
            Self::Common(source) => source.clone(),
            Self::Sticker(source) => source.clone().into(),
            Self::Uri(uri) => CommonMediaSource::Plain(uri.clone()),
        }
    }
}

impl<'a> From<&'a CommonMediaSource> for MediaSource<'a> {
    fn from(value: &'a CommonMediaSource) -> Self {
        Self::Common(value)
    }
}

impl<'a> From<&'a StickerMediaSource> for MediaSource<'a> {
    fn from(value: &'a StickerMediaSource) -> Self {
        Self::Sticker(value)
    }
}

impl<'a> From<&'a OwnedMxcUri> for MediaSource<'a> {
    fn from(value: &'a OwnedMxcUri) -> Self {
        Self::Uri(value)
    }
}

/// Information about the source of an image.
#[derive(Debug, Clone, Copy, Default)]
pub struct ImageSourceInfo<'a> {
    /// The dimensions of the image.
    dimensions: Option<FrameDimensions>,
    /// The MIME type of the image.
    mimetype: Option<&'a str>,
    /// The file size of the image.
    filesize: Option<u32>,
}

impl<'a> From<&'a ImageInfo> for ImageSourceInfo<'a> {
    fn from(value: &'a ImageInfo) -> Self {
        Self {
            dimensions: FrameDimensions::from_options(value.width, value.height),
            mimetype: value.mimetype.as_deref(),
            filesize: value.size.and_then(|u| u.try_into().ok()),
        }
    }
}

impl<'a> From<&'a ThumbnailInfo> for ImageSourceInfo<'a> {
    fn from(value: &'a ThumbnailInfo) -> Self {
        Self {
            dimensions: FrameDimensions::from_options(value.width, value.height),
            mimetype: value.mimetype.as_deref(),
            filesize: value.size.and_then(|u| u.try_into().ok()),
        }
    }
}

impl<'a> From<&'a AvatarImageInfo> for ImageSourceInfo<'a> {
    fn from(value: &'a AvatarImageInfo) -> Self {
        Self {
            dimensions: FrameDimensions::from_options(value.width, value.height),
            mimetype: value.mimetype.as_deref(),
            filesize: value.size.and_then(|u| u.try_into().ok()),
        }
    }
}

/// The settings for downloading a thumbnail.
#[derive(Debug, Clone)]
pub struct ThumbnailSettings {
    /// The requested dimensions of the thumbnail.
    pub dimensions: FrameDimensions,
    /// The method to use to resize the thumbnail.
    pub method: Method,
    /// Whether to request an animated thumbnail.
    pub animated: bool,
    /// Whether we should prefer to get a thumbnail if dimensions are unknown.
    ///
    /// This is particularly useful for avatars where we will prefer to save
    /// bandwidth and memory usage as we download a lot of them and they might
    /// appear several times on the screen. For media messages, we will on the
    /// contrary prefer to download the original content to reduce the space
    /// taken in the media cache.
    pub prefer_thumbnail: bool,
}

impl From<ThumbnailSettings> for MediaThumbnailSettings {
    fn from(value: ThumbnailSettings) -> Self {
        let ThumbnailSettings {
            dimensions,
            method,
            animated,
            ..
        } = value;

        MediaThumbnailSettings {
            method,
            width: dimensions.width.into(),
            height: dimensions.height.into(),
            animated,
        }
    }
}

/// An error encountered when loading an image.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageError {
    /// Could not download the image.
    Download,
    /// Could not save the image to a temporary file.
    File,
    /// The image uses an unsupported format.
    UnsupportedFormat,
    /// An I/O error occurred when loading the image with glycin.
    Io,
    /// An unexpected error occurred.
    Unknown,
    /// The request for the image was aborted.
    Aborted,
}

impl Error for ImageError {}

impl fmt::Display for ImageError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            Self::Download => gettext("Could not retrieve media"),
            Self::UnsupportedFormat => gettext("Image format not supported"),
            Self::File | Self::Io | Self::Unknown | Self::Aborted => {
                gettext("An unexpected error occurred")
            }
        };

        f.write_str(&s)
    }
}

impl From<MediaFileError> for ImageError {
    fn from(value: MediaFileError) -> Self {
        match value {
            MediaFileError::Sdk(_) => Self::Download,
            MediaFileError::File(_) => Self::File,
        }
    }
}

impl From<glycin::ErrorCtx> for ImageError {
    fn from(value: glycin::ErrorCtx) -> Self {
        if value.unsupported_format().is_some() {
            Self::UnsupportedFormat
        } else if matches!(value.error(), glycin::Error::StdIoError { .. }) {
            Self::Io
        } else {
            Self::Unknown
        }
    }
}
