use std::fs;
use std::path::Path;

use crate::db::DbConnection as Connection;
use uuid::Uuid;
use walkdir::WalkDir;

use crate::db::fts;
use crate::db::queries::media as qm;
use crate::db::queries::media_translation as qmt;
use crate::db::queries::post as qp;
use crate::db::queries::post_media as qpm;
use crate::engine::{EngineError, EngineResult, domain_events};
use crate::model::{DomainEntity, Media, MediaTranslation, NotificationAction, PostMedia};
use crate::util::sidecar::{
    MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar,
};
use crate::util::thumbnail::{
    THUMBNAIL_SIZES, ThumbnailFormat, generate_all_thumbnails, image_dimensions,
    mime_from_extension,
};
use crate::util::{
    atomic_write_str, media_dir_path, media_file_hash, media_sidecar_path,
    media_translation_sidecar_path, now_unix_ms,
};

/// Report returned by `rebuild_media_from_filesystem`.
#[derive(Debug, Default)]
pub struct MediaRebuildReport {
    pub media_created: usize,
    pub media_updated: usize,
    pub translations_created: usize,
    pub translations_updated: usize,
    pub errors: Vec<String>,
}

#[derive(Debug, Default, PartialEq, Eq)]
pub struct MediaLinkRebuildReport {
    pub links: usize,
}

#[derive(Debug, Default, PartialEq, Eq)]
pub struct ThumbnailRepairReport {
    pub media_processed: usize,
    pub media_repaired: usize,
    pub media_failed: usize,
    pub thumbnails_generated: usize,
}

/// Rebuild the exact post/media relationship set stored in canonical media
/// sidecars. Stale database links are removed as well as missing links added.
pub fn rebuild_media_links(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<MediaLinkRebuildReport> {
    rebuild_media_links_with_progress(conn, data_dir, project_id, None)
}

pub fn rebuild_media_links_with_progress(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    on_item: Option<ItemProgressFn>,
) -> EngineResult<MediaLinkRebuildReport> {
    conn.begin_savepoint()?;
    match rebuild_media_links_inner(conn, data_dir, project_id, on_item.as_ref()) {
        Ok(report) => {
            conn.release_savepoint()?;
            Ok(report)
        }
        Err(error) => {
            let _ = conn.rollback_savepoint();
            Err(error)
        }
    }
}

fn rebuild_media_links_inner(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    on_item: Option<&ItemProgressFn>,
) -> EngineResult<MediaLinkRebuildReport> {
    let mut report = MediaLinkRebuildReport::default();
    let items = qm::list_media_by_project(conn, project_id)?;
    for (index, item) in items.iter().enumerate() {
        if let Some(callback) = on_item
            && !callback(index + 1, items.len(), &item.original_name)
        {
            return Err(EngineError::Cancelled);
        }
        let sidecar = read_sidecar(&fs::read_to_string(data_dir.join(&item.sidecar_path))?)
            .map_err(EngineError::Parse)?;
        for link in qpm::list_post_media_by_media(conn, &item.id)? {
            qpm::unlink_media(conn, &link.post_id, &item.id)?;
        }
        for (sort_order, post_id) in sidecar.linked_post_ids.into_iter().enumerate() {
            let post = qp::get_post_by_id(conn, &post_id)?;
            if post.project_id != project_id {
                return Err(EngineError::Validation(format!(
                    "media {} sidecar links to a post outside the active project",
                    item.id
                )));
            }
            qpm::link_media(
                conn,
                &PostMedia {
                    id: Uuid::new_v4().to_string(),
                    project_id: project_id.to_string(),
                    post_id,
                    media_id: item.id.clone(),
                    sort_order: sort_order as i32,
                    created_at: now_unix_ms(),
                },
            )?;
            report.links += 1;
        }
    }
    Ok(report)
}

/// Regenerate all standard thumbnail variants for items missing at least one
/// variant. Existing complete sets are left untouched.
pub fn regenerate_missing_thumbnails(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<ThumbnailRepairReport> {
    regenerate_missing_thumbnails_with_progress(conn, data_dir, project_id, |_, _, _| true)
}

pub fn regenerate_missing_thumbnails_with_progress(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    mut on_item: impl FnMut(usize, usize, &str) -> bool,
) -> EngineResult<ThumbnailRepairReport> {
    let mut report = ThumbnailRepairReport::default();
    let items = qm::list_media_by_project(conn, project_id)?;
    for (index, item) in items.iter().enumerate() {
        if !on_item(index + 1, items.len(), &item.original_name) {
            return Err(EngineError::Cancelled);
        }
        if !item.mime_type.starts_with("image/") || item.mime_type.contains("svg") {
            continue;
        }
        report.media_processed += 1;
        let prefix = &item.id[..2.min(item.id.len())];
        let missing = THUMBNAIL_SIZES
            .iter()
            .filter(|size| {
                let extension = match size.format {
                    ThumbnailFormat::Webp => "webp",
                    ThumbnailFormat::Jpeg => "jpg",
                };
                !data_dir
                    .join("thumbnails")
                    .join(prefix)
                    .join(format!("{}-{}.{}", item.id, size.name, extension))
                    .is_file()
            })
            .count();
        if missing == 0 {
            continue;
        }
        match generate_all_thumbnails(
            &data_dir.join(&item.file_path),
            &data_dir.join("thumbnails"),
            &item.id,
        ) {
            Ok(_) => {
                report.media_repaired += 1;
                report.thumbnails_generated += missing;
            }
            Err(_) => report.media_failed += 1,
        }
    }
    Ok(report)
}

/// Supported image MIME types for import (per media_processing.allium).
const SUPPORTED_IMAGE_TYPES: &[&str] = &[
    "image/jpeg",
    "image/png",
    "image/gif",
    "image/webp",
    "image/tiff",
    "image/bmp",
    "image/heic",
    "image/heif",
];

pub fn is_supported_image_path(path: &Path) -> bool {
    path.extension()
        .and_then(|extension| extension.to_str())
        .map(mime_from_extension)
        .is_some_and(|mime_type| SUPPORTED_IMAGE_TYPES.contains(&mime_type))
}

/// Import a media file (image, etc.) into the project.
#[expect(
    clippy::too_many_arguments,
    reason = "arguments are the user-supplied media metadata fields"
)]
pub fn import_media(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    source_path: &Path,
    original_name: &str,
    title: Option<&str>,
    alt: Option<&str>,
    caption: Option<&str>,
    author: Option<&str>,
    language: Option<&str>,
    tags: Vec<String>,
) -> EngineResult<Media> {
    import_media_at(
        conn,
        data_dir,
        project_id,
        source_path,
        original_name,
        title,
        alt,
        caption,
        author,
        language,
        tags,
        None,
        now_unix_ms(),
    )
}

/// Import media while preserving a trusted source timestamp. Importers use
/// this path so the canonical date-based location and sidecar metadata match
/// the source system; interactive imports continue to use the current time.
#[expect(
    clippy::too_many_arguments,
    reason = "arguments are the user-supplied media metadata fields plus source timestamp"
)]
pub(crate) fn import_media_at(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    source_path: &Path,
    original_name: &str,
    title: Option<&str>,
    alt: Option<&str>,
    caption: Option<&str>,
    author: Option<&str>,
    language: Option<&str>,
    tags: Vec<String>,
    checksum: Option<&str>,
    created_at: i64,
) -> EngineResult<Media> {
    // Validate file type per spec
    let ext = Path::new(original_name)
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("bin");
    let mime_type = mime_from_extension(ext).to_string();
    if !is_supported_image_path(Path::new(original_name)) {
        return Err(EngineError::Validation(format!(
            "unsupported file type: {mime_type} (file: {original_name})"
        )));
    }

    let id = Uuid::new_v4().to_string();
    let filename = format!("{id}.{ext}");

    // Compute target directory and copy file
    let dir_path = media_dir_path(created_at);
    let rel_file_path = format!("{dir_path}{filename}");
    let abs_file_path = data_dir.join(&rel_file_path);

    if let Some(parent) = abs_file_path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::copy(source_path, &abs_file_path)?;

    // Get image dimensions
    let (width, height) = image_dimensions(&abs_file_path)
        .map(|(w, h)| (Some(w as i32), Some(h as i32)))
        .unwrap_or((None, None));

    // Get file size
    let file_size = fs::metadata(&abs_file_path)?.len() as i64;

    // Compute sidecar path
    let sidecar_rel = media_sidecar_path(&rel_file_path);

    // Generate thumbnails (silently ignore errors for non-image files)
    let thumbnails_dir = data_dir.join("thumbnails");
    let _ = generate_all_thumbnails(&abs_file_path, &thumbnails_dir, &id);

    let media = Media {
        id: id.clone(),
        project_id: project_id.to_string(),
        filename,
        original_name: original_name.to_string(),
        mime_type,
        size: file_size,
        width,
        height,
        title: title.map(|s| s.to_string()),
        alt: alt.map(|s| s.to_string()),
        caption: caption.map(|s| s.to_string()),
        author: author.map(|s| s.to_string()),
        language: language.map(|s| s.to_string()),
        file_path: rel_file_path,
        sidecar_path: sidecar_rel.clone(),
        checksum: checksum.map(str::to_owned),
        tags,
        created_at,
        updated_at: created_at,
    };

    // Write sidecar
    let sidecar = MediaSidecar::from_media(&media, &[]);
    let abs_sidecar = data_dir.join(&sidecar_rel);
    atomic_write_str(&abs_sidecar, &sidecar.to_string())?;

    // Insert into DB
    qm::insert_media(conn, &media)?;

    // Index in FTS
    fts_index_media(conn, &media)?;

    emit_media(&media, NotificationAction::Created);

    Ok(media)
}

/// Update a media item's metadata fields.
#[expect(
    clippy::too_many_arguments,
    reason = "optional arguments represent independent media field changes"
)]
pub fn update_media(
    conn: &Connection,
    data_dir: &Path,
    media_id: &str,
    title: Option<Option<&str>>,
    alt: Option<Option<&str>>,
    caption: Option<Option<&str>>,
    author: Option<Option<&str>>,
    language: Option<Option<&str>>,
    tags: Option<Vec<String>>,
) -> EngineResult<Media> {
    let mut media = qm::get_media_by_id(conn, media_id)?;

    if let Some(t) = title {
        media.title = t.map(|s| s.to_string());
    }
    if let Some(a) = alt {
        media.alt = a.map(|s| s.to_string());
    }
    if let Some(c) = caption {
        media.caption = c.map(|s| s.to_string());
    }
    if let Some(a) = author {
        media.author = a.map(|s| s.to_string());
    }
    if let Some(l) = language {
        media.language = l.map(|s| s.to_string());
    }
    if let Some(t) = tags {
        media.tags = t;
    }

    media.updated_at = now_unix_ms();
    qm::update_media(conn, &media)?;

    sync_media_sidecar(conn, data_dir, media_id)?;

    // Re-index FTS
    fts_index_media(conn, &media)?;

    emit_media(&media, NotificationAction::Updated);

    Ok(media)
}

/// Rewrite a media item's canonical sidecar from its current database state.
pub fn sync_media_sidecar(conn: &Connection, data_dir: &Path, media_id: &str) -> EngineResult<()> {
    let media = qm::get_media_by_id(conn, media_id).map_err(|error| match error {
        diesel::result::Error::NotFound => EngineError::NotFound(format!("media {media_id}")),
        error => EngineError::Db(error),
    })?;
    let linked_post_ids = qpm::list_post_media_by_media(conn, media_id)?
        .into_iter()
        .map(|link| link.post_id)
        .collect::<Vec<_>>();
    let sidecar = MediaSidecar::from_media(&media, &linked_post_ids);
    atomic_write_str(&data_dir.join(&media.sidecar_path), &sidecar.to_string())?;
    Ok(())
}

/// Replace a media item's binary while preserving its identity, public path,
/// metadata, translations, and post links. Returns `None` when the new file is
/// byte-for-byte identical to the stored binary.
pub fn replace_media_file(
    conn: &Connection,
    data_dir: &Path,
    media_id: &str,
    new_source_path: &Path,
) -> EngineResult<Option<Media>> {
    let mut media = qm::get_media_by_id(conn, media_id)
        .map_err(|_| EngineError::NotFound(format!("media {media_id}")))?;
    if !new_source_path.is_file() {
        return Err(EngineError::Validation(format!(
            "replacement file does not exist: {}",
            new_source_path.display()
        )));
    }

    let replacement_checksum = media_file_hash(new_source_path)?;
    if media.checksum.as_deref() == Some(replacement_checksum.as_str()) {
        return Ok(None);
    }

    let destination = data_dir.join(&media.file_path);
    if !destination.is_file() {
        return Err(EngineError::NotFound(format!(
            "stored media file {}",
            destination.display()
        )));
    }

    let (width, height) = image_dimensions(new_source_path)
        .map(|(w, h)| (Some(w as i32), Some(h as i32)))
        .map_err(|error| {
            EngineError::Validation(format!(
                "replacement is not a readable image '{}': {error}",
                new_source_path.display(),
            ))
        })?;
    let replacement_size = fs::metadata(new_source_path)?.len() as i64;

    // Generate all derived files before touching the canonical binary.
    let staged_thumbnails = data_dir
        .join("thumbnails")
        .join(format!(".replace-{media_id}"));
    if staged_thumbnails.exists() {
        fs::remove_dir_all(&staged_thumbnails)?;
    }
    let staged_paths = generate_all_thumbnails(new_source_path, &staged_thumbnails, media_id)
        .map_err(EngineError::Parse)?;

    let backup = destination.with_extension(format!(
        "{}.bak",
        destination
            .extension()
            .and_then(|extension| extension.to_str())
            .unwrap_or("media")
    ));
    if backup.exists() {
        fs::remove_file(&backup)?;
    }
    fs::rename(&destination, &backup)?;
    if let Err(error) = fs::copy(new_source_path, &destination) {
        let _ = fs::rename(&backup, &destination);
        let _ = fs::remove_dir_all(&staged_thumbnails);
        return Err(error.into());
    }

    let previous_media = media.clone();
    let previous_sidecar = fs::read_to_string(data_dir.join(&media.sidecar_path)).ok();
    media.size = replacement_size;
    media.width = width;
    media.height = height;
    media.checksum = Some(replacement_checksum);
    media.updated_at = now_unix_ms();

    let apply_result = (|| -> EngineResult<()> {
        conn.begin_savepoint()?;
        qm::update_media(conn, &media)?;
        sync_media_sidecar(conn, data_dir, media_id)?;
        fts_index_media(conn, &media)?;

        for staged in &staged_paths {
            let staged = Path::new(staged);
            let relative = staged.strip_prefix(&staged_thumbnails).map_err(|error| {
                EngineError::Validation(format!("invalid staged thumbnail path: {error}"))
            })?;
            let target = data_dir.join("thumbnails").join(relative);
            if let Some(parent) = target.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::copy(staged, target)?;
        }
        conn.release_savepoint()?;
        Ok(())
    })();

    let _ = fs::remove_dir_all(&staged_thumbnails);
    match apply_result {
        Ok(()) => {
            fs::remove_file(backup)?;
            emit_media(&media, NotificationAction::Updated);
            Ok(Some(media))
        }
        Err(error) => {
            let _ = conn.rollback_savepoint();
            let _ = fs::remove_file(&destination);
            let _ = fs::rename(&backup, &destination);
            let _ = qm::update_media(conn, &previous_media);
            if let Some(previous_sidecar) = previous_sidecar {
                let _ = atomic_write_str(
                    &data_dir.join(&previous_media.sidecar_path),
                    &previous_sidecar,
                );
            }
            Err(error)
        }
    }
}

/// Delete a media item and all related artifacts.
pub fn delete_media(conn: &Connection, data_dir: &Path, media_id: &str) -> EngineResult<()> {
    let media = qm::get_media_by_id(conn, media_id)?;

    // Delete binary file
    let abs_file = data_dir.join(&media.file_path);
    if abs_file.exists() {
        fs::remove_file(&abs_file)?;
    }

    // Delete sidecar file
    let abs_sidecar = data_dir.join(&media.sidecar_path);
    if abs_sidecar.exists() {
        fs::remove_file(&abs_sidecar)?;
    }

    // Delete all translation sidecar files
    let translations = qmt::list_media_translations_by_media(conn, media_id).unwrap_or_default();
    for t in &translations {
        let trans_sidecar = media_translation_sidecar_path(&media.file_path, &t.language);
        let abs_trans = data_dir.join(&trans_sidecar);
        if abs_trans.exists() {
            fs::remove_file(&abs_trans)?;
        }
    }

    // Delete all thumbnails
    let ext_map = |fmt: &ThumbnailFormat| -> &str {
        match fmt {
            ThumbnailFormat::Webp => "webp",
            ThumbnailFormat::Jpeg => "jpg",
        }
    };
    let prefix = &media_id[..2.min(media_id.len())];
    for size in THUMBNAIL_SIZES {
        let ext = ext_map(&size.format);
        let thumb_rel = format!("thumbnails/{prefix}/{media_id}-{}.{ext}", size.name);
        let abs_thumb = data_dir.join(&thumb_rel);
        if abs_thumb.exists() {
            let _ = fs::remove_file(&abs_thumb);
        }
    }

    // Delete media translations from DB
    for t in &translations {
        qmt::delete_media_translation(conn, media_id, &t.language)?;
    }

    // Delete post_media links from DB
    let links = qpm::list_post_media_by_media(conn, media_id).unwrap_or_default();
    for link in &links {
        qpm::unlink_media(conn, &link.post_id, media_id)?;
    }

    // Remove from FTS index
    fts::remove_media_from_index(conn, media_id)?;

    // Delete from media table
    qm::delete_media(conn, media_id)?;

    emit_media(&media, NotificationAction::Deleted);

    Ok(())
}

fn emit_media(media: &Media, action: NotificationAction) {
    domain_events::entity_changed(&media.project_id, DomainEntity::Media, &media.id, action);
}

/// Create or update a translation for a media item.
pub fn upsert_media_translation(
    conn: &Connection,
    data_dir: &Path,
    media_id: &str,
    language: &str,
    title: Option<&str>,
    alt: Option<&str>,
    caption: Option<&str>,
) -> EngineResult<MediaTranslation> {
    let media = qm::get_media_by_id(conn, media_id)?;
    let now = now_unix_ms();

    // Check if translation already exists
    let existing = qmt::get_media_translation_by_media_and_language(conn, media_id, language);
    let translation = match existing {
        Ok(mut t) => {
            t.title = title.map(|s| s.to_string());
            t.alt = alt.map(|s| s.to_string());
            t.caption = caption.map(|s| s.to_string());
            t.updated_at = now;
            qmt::upsert_media_translation(conn, &t)?;
            t
        }
        Err(_) => {
            let t = MediaTranslation {
                id: Uuid::new_v4().to_string(),
                project_id: media.project_id.clone(),
                translation_for: media_id.to_string(),
                language: language.to_string(),
                title: title.map(|s| s.to_string()),
                alt: alt.map(|s| s.to_string()),
                caption: caption.map(|s| s.to_string()),
                created_at: now,
                updated_at: now,
            };
            qmt::upsert_media_translation(conn, &t)?;
            t
        }
    };

    // Write translation sidecar file
    let trans_sidecar = MediaTranslationSidecar {
        translation_for: media_id.to_string(),
        language: language.to_string(),
        title: title.map(|s| s.to_string()),
        alt: alt.map(|s| s.to_string()),
        caption: caption.map(|s| s.to_string()),
    };
    let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
    let abs_sidecar = data_dir.join(&sidecar_rel);
    atomic_write_str(&abs_sidecar, &trans_sidecar.to_string())?;

    // Re-index FTS for parent media
    fts_index_media(conn, &media)?;
    emit_media(&media, NotificationAction::Updated);

    Ok(translation)
}

/// Delete a media translation and its sidecar file.
pub fn delete_media_translation(
    conn: &Connection,
    data_dir: &Path,
    media_id: &str,
    language: &str,
) -> EngineResult<()> {
    let media = qm::get_media_by_id(conn, media_id)?;

    // Delete translation sidecar file
    let sidecar_rel = media_translation_sidecar_path(&media.file_path, language);
    let abs_sidecar = data_dir.join(&sidecar_rel);
    if abs_sidecar.exists() {
        fs::remove_file(&abs_sidecar)?;
    }

    // Delete from DB
    qmt::delete_media_translation(conn, media_id, language)?;

    // Re-index FTS for parent media
    fts_index_media(conn, &media)?;

    Ok(())
}

/// Rebuild media entries from filesystem. Walk `media/` directory, parse sidecars, upsert into DB.
pub fn rebuild_media_from_filesystem(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
) -> EngineResult<MediaRebuildReport> {
    rebuild_media_from_filesystem_with_progress(conn, data_dir, project_id, None)
}

/// Per-item progress callback: (current_item, total_items, item_description).
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;

/// Like `rebuild_media_from_filesystem` but with optional per-item progress.
pub fn rebuild_media_from_filesystem_with_progress(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    on_item: Option<ItemProgressFn>,
) -> EngineResult<MediaRebuildReport> {
    let mut report = MediaRebuildReport::default();
    let media_dir = data_dir.join("media");

    if !media_dir.exists() {
        return Ok(report);
    }

    let mut canonical_sidecars = Vec::new();
    let mut translation_sidecars = Vec::new();

    for entry in WalkDir::new(&media_dir).into_iter().filter_map(|e| e.ok()) {
        let path = entry.path();
        if !path.is_file() {
            continue;
        }
        let file_name = match path.file_name().and_then(|n| n.to_str()) {
            Some(n) => n,
            None => continue,
        };

        if !file_name.ends_with(".meta") {
            continue;
        }

        if is_translation_sidecar(file_name) {
            translation_sidecars.push(path.to_path_buf());
        } else {
            canonical_sidecars.push(path.to_path_buf());
        }
    }

    let total = canonical_sidecars.len() + translation_sidecars.len();

    // Process canonical sidecars first
    for (i, path) in canonical_sidecars.iter().enumerate() {
        if let Some(ref cb) = on_item {
            let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
            if !cb(i + 1, total, name) {
                return Err(EngineError::Cancelled);
            }
        }
        match rebuild_canonical_media(conn, data_dir, project_id, path) {
            Ok(created) => {
                if created {
                    report.media_created += 1;
                } else {
                    report.media_updated += 1;
                }
            }
            Err(e) => {
                report.errors.push(format!("{}: {e}", path.display()));
            }
        }
    }

    // Process translation sidecars
    let offset = canonical_sidecars.len();
    for (i, path) in translation_sidecars.iter().enumerate() {
        if let Some(ref cb) = on_item {
            let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
            if !cb(offset + i + 1, total, name) {
                return Err(EngineError::Cancelled);
            }
        }
        match rebuild_translation_sidecar(conn, data_dir, project_id, path) {
            Ok(created) => {
                if created {
                    report.translations_created += 1;
                } else {
                    report.translations_updated += 1;
                }
            }
            Err(e) => {
                report.errors.push(format!("{}: {e}", path.display()));
            }
        }
    }

    // Re-index FTS for all media in this project
    let all_media = qm::list_media_by_project(conn, project_id)?;
    for m in &all_media {
        fts_index_media(conn, m)?;
    }

    Ok(report)
}

// --- Internal helpers ---

/// Index a media item in FTS, gathering translation texts.
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
    let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
    let translation_data: Vec<fts::MediaTranslationFts> = translations
        .iter()
        .map(|t| fts::MediaTranslationFts {
            title: t.title.clone(),
            alt: t.alt.clone(),
            caption: t.caption.clone(),
            language: t.language.clone(),
        })
        .collect();

    let lang = media.language.as_deref().unwrap_or("en");
    fts::index_media(
        conn,
        &media.id,
        media.title.as_deref(),
        media.alt.as_deref(),
        media.caption.as_deref(),
        &media.original_name,
        &media.tags,
        &translation_data,
        lang,
    )?;
    Ok(())
}

/// Check if a .meta filename is a translation sidecar: `{name}.{lang}.meta`
/// where lang is exactly 2 lowercase ASCII letters.
fn is_translation_sidecar(file_name: &str) -> bool {
    // file_name ends with .meta
    // Strip .meta suffix, then check if remaining ends with .{2-letter-lang}
    let without_meta = match file_name.strip_suffix(".meta") {
        Some(s) => s,
        None => return false,
    };
    // Find the last dot in what remains
    if let Some(dot_pos) = without_meta.rfind('.') {
        let suffix = &without_meta[dot_pos + 1..];
        // Must also have another dot before this (the extension of the media file)
        // e.g. "foo.jpg.de" -> dot_pos points to the dot before "de"
        // "foo.jpg" would be the canonical sidecar (no extra dot+lang)
        suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase()) && dot_pos > 0
    } else {
        false
    }
}

/// Rebuild a canonical media from its sidecar file. Returns true if created, false if updated.
pub(crate) fn rebuild_canonical_media(
    conn: &Connection,
    data_dir: &Path,
    project_id: &str,
    sidecar_path: &Path,
) -> EngineResult<bool> {
    let content = fs::read_to_string(sidecar_path)?;
    let sc = read_sidecar(&content).map_err(EngineError::Parse)?;

    // Derive file_path: the sidecar path minus ".meta" suffix, relative to data_dir
    let sidecar_rel = sidecar_path
        .strip_prefix(data_dir)
        .unwrap_or(sidecar_path)
        .to_string_lossy()
        .to_string();
    let file_path = sidecar_rel
        .strip_suffix(".meta")
        .unwrap_or(&sidecar_rel)
        .to_string();

    // Derive filename from file_path
    let filename = Path::new(&file_path)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_string();

    let linked_post_ids = sc.linked_post_ids.clone();

    let existing = qm::get_media_by_id(conn, &sc.id);
    let created = match existing {
        Ok(mut media) => {
            // Update existing media
            media.original_name = sc.original_name;
            media.mime_type = sc.mime_type;
            media.size = sc.size;
            media.width = sc.width;
            media.height = sc.height;
            media.title = sc.title;
            media.alt = sc.alt;
            media.caption = sc.caption;
            media.author = sc.author;
            media.language = sc.language;
            media.file_path = file_path;
            media.sidecar_path = sidecar_rel;
            media.checksum = None;
            media.tags = sc.tags;
            media.updated_at = sc.updated_at;
            qm::update_media(conn, &media)?;
            false
        }
        Err(_) => {
            let media = Media {
                id: sc.id.clone(),
                project_id: project_id.to_string(),
                filename,
                original_name: sc.original_name,
                mime_type: sc.mime_type,
                size: sc.size,
                width: sc.width,
                height: sc.height,
                title: sc.title,
                alt: sc.alt,
                caption: sc.caption,
                author: sc.author,
                language: sc.language,
                file_path,
                sidecar_path: sidecar_rel,
                checksum: None,
                tags: sc.tags,
                created_at: sc.created_at,
                updated_at: sc.updated_at,
            };
            qm::insert_media(conn, &media)?;
            true
        }
    };

    let existing_post_ids: std::collections::HashSet<_> =
        qpm::list_post_media_by_media(conn, &sc.id)?
            .into_iter()
            .map(|link| link.post_id)
            .collect();
    for post_id in linked_post_ids {
        if !existing_post_ids.contains(&post_id) && qp::get_post_by_id(conn, &post_id).is_ok() {
            qpm::link_media(
                conn,
                &PostMedia {
                    id: Uuid::new_v4().to_string(),
                    project_id: project_id.to_string(),
                    post_id,
                    media_id: sc.id.clone(),
                    sort_order: 0,
                    created_at: now_unix_ms(),
                },
            )?;
        }
    }

    Ok(created)
}

/// Rebuild a translation from a `*.{lang}.meta` sidecar. Returns true if created, false if updated.
pub(crate) fn rebuild_translation_sidecar(
    conn: &Connection,
    _data_dir: &Path,
    project_id: &str,
    sidecar_path: &Path,
) -> EngineResult<bool> {
    let content = fs::read_to_string(sidecar_path)?;
    let sc = read_translation_sidecar(&content).map_err(EngineError::Parse)?;

    // Check parent media exists
    let _media = qm::get_media_by_id(conn, &sc.translation_for).map_err(|_| {
        EngineError::NotFound(format!(
            "parent media '{}' not found for translation",
            sc.translation_for
        ))
    })?;

    let now = now_unix_ms();

    let existing =
        qmt::get_media_translation_by_media_and_language(conn, &sc.translation_for, &sc.language);
    match existing {
        Ok(mut t) => {
            t.title = sc.title;
            t.alt = sc.alt;
            t.caption = sc.caption;
            t.updated_at = now;
            qmt::upsert_media_translation(conn, &t)?;
            Ok(false)
        }
        Err(_) => {
            let t = MediaTranslation {
                id: Uuid::new_v4().to_string(),
                project_id: project_id.to_string(),
                translation_for: sc.translation_for,
                language: sc.language,
                title: sc.title,
                alt: sc.alt,
                caption: sc.caption,
                created_at: now,
                updated_at: now,
            };
            qmt::upsert_media_translation(conn, &t)?;
            Ok(true)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::Database;
    use crate::db::fts::ensure_fts_tables;
    use crate::db::queries::project::{insert_project, make_test_project};
    use image::DynamicImage;
    use tempfile::TempDir;

    fn setup() -> (Database, TempDir) {
        let db = Database::open_in_memory().unwrap();
        db.migrate().unwrap();
        ensure_fts_tables(db.conn()).unwrap();
        insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap();
        let dir = TempDir::new().unwrap();
        (db, dir)
    }

    /// Create a simple 100x80 PNG in the given directory.
    fn create_test_png(dir: &Path) -> std::path::PathBuf {
        let path = dir.join("test-source.png");
        let img = DynamicImage::new_rgb8(100, 80);
        img.save(&path).unwrap();
        path
    }

    fn heic_fixture() -> std::path::PathBuf {
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("testdata")
            .join("close.heic")
    }

    #[test]
    fn import_media_creates_artifacts() {
        let (db, dir) = setup();
        let source = create_test_png(dir.path());

        let media = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &source,
            "photo.png",
            Some("My Photo"),
            Some("A photo"),
            None,
            Some("Alice"),
            Some("en"),
            vec!["nature".into()],
        )
        .unwrap();

        // Verify DB entry
        let from_db = qm::get_media_by_id(db.conn(), &media.id).unwrap();
        assert_eq!(from_db.original_name, "photo.png");
        assert_eq!(from_db.title.as_deref(), Some("My Photo"));
        assert_eq!(from_db.alt.as_deref(), Some("A photo"));
        assert_eq!(from_db.author.as_deref(), Some("Alice"));
        assert_eq!(from_db.language.as_deref(), Some("en"));
        assert_eq!(from_db.tags, vec!["nature"]);
        assert_eq!(from_db.mime_type, "image/png");
        assert_eq!(from_db.width, Some(100));
        assert_eq!(from_db.height, Some(80));
        assert_eq!(from_db.checksum, None);
        assert!(from_db.size > 0);

        // Verify binary file exists
        let abs_file = dir.path().join(&from_db.file_path);
        assert!(abs_file.exists(), "binary file should exist");

        // Verify sidecar exists
        let abs_sidecar = dir.path().join(&from_db.sidecar_path);
        assert!(abs_sidecar.exists(), "sidecar should exist");

        // Verify sidecar content is parseable
        let sidecar_content = fs::read_to_string(&abs_sidecar).unwrap();
        let sc = read_sidecar(&sidecar_content).unwrap();
        assert_eq!(sc.id, media.id);
        assert_eq!(sc.original_name, "photo.png");

        // Verify thumbnails exist
        let prefix = &media.id[..2];
        for size in THUMBNAIL_SIZES {
            let ext = match size.format {
                ThumbnailFormat::Webp => "webp",
                ThumbnailFormat::Jpeg => "jpg",
            };
            let thumb = dir
                .path()
                .join("thumbnails")
                .join(prefix)
                .join(format!("{}-{}.{ext}", media.id, size.name));
            assert!(thumb.exists(), "thumbnail {} should exist", size.name);
        }
    }

    #[test]
    fn import_heic_and_heif_create_dimensions_and_thumbnails() {
        let (db, dir) = setup();

        for (original_name, mime_type) in
            [("photo.heic", "image/heic"), ("photo.heif", "image/heif")]
        {
            let media = import_media(
                db.conn(),
                dir.path(),
                "p1",
                &heic_fixture(),
                original_name,
                None,
                None,
                None,
                None,
                None,
                vec![],
            )
            .unwrap();

            assert_eq!(media.mime_type, mime_type);
            assert_eq!((media.width, media.height), (Some(27), Some(27)));
            for size in THUMBNAIL_SIZES {
                let extension = match size.format {
                    ThumbnailFormat::Webp => "webp",
                    ThumbnailFormat::Jpeg => "jpg",
                };
                assert!(
                    dir.path()
                        .join("thumbnails")
                        .join(&media.id[..2])
                        .join(format!("{}-{}.{}", media.id, size.name, extension))
                        .is_file(),
                    "missing {} thumbnail",
                    size.name
                );
            }
        }
    }

    #[test]
    fn regenerate_missing_thumbnails_reports_undecodable_media_and_continues() {
        let (db, dir) = setup();
        let good = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &create_test_png(dir.path()),
            "good.png",
            None,
            None,
            None,
            None,
            None,
            vec![],
        )
        .unwrap();
        let bad_source = dir.path().join("bad.png");
        fs::write(&bad_source, "not an image").unwrap();
        let bad = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &bad_source,
            "bad.png",
            None,
            None,
            None,
            None,
            None,
            vec![],
        )
        .unwrap();
        let good_small = dir
            .path()
            .join("thumbnails")
            .join(&good.id[..2])
            .join(format!("{}-small.webp", good.id));
        fs::remove_file(good_small).unwrap();

        let report = regenerate_missing_thumbnails(db.conn(), dir.path(), "p1").unwrap();

        assert_eq!(report.media_processed, 2);
        assert_eq!(report.media_repaired, 1);
        assert_eq!(report.media_failed, 1);
        assert_eq!(report.thumbnails_generated, 1);
        assert!(
            !dir.path()
                .join("thumbnails")
                .join(&bad.id[..2])
                .join(format!("{}-small.webp", bad.id))
                .exists()
        );
    }

    #[test]
    fn update_media_rewrites_sidecar() {
        let (db, dir) = setup();
        let source = create_test_png(dir.path());

        let media = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &source,
            "photo.png",
            Some("Original Title"),
            None,
            None,
            None,
            None,
            vec![],
        )
        .unwrap();

        // Read original sidecar
        let abs_sidecar = dir.path().join(&media.sidecar_path);
        let original_content = fs::read_to_string(&abs_sidecar).unwrap();

        // Update
        let updated = update_media(
            db.conn(),
            dir.path(),
            &media.id,
            Some(Some("New Title")),
            Some(Some("New alt")),
            None,
            None,
            None,
            Some(vec!["updated-tag".into()]),
        )
        .unwrap();

        assert_eq!(updated.title.as_deref(), Some("New Title"));
        assert_eq!(updated.alt.as_deref(), Some("New alt"));
        assert_eq!(updated.tags, vec!["updated-tag"]);

        // Verify sidecar was rewritten
        let new_content = fs::read_to_string(&abs_sidecar).unwrap();
        assert_ne!(original_content, new_content);
        let sc = read_sidecar(&new_content).unwrap();
        assert_eq!(sc.title.as_deref(), Some("New Title"));
        assert_eq!(sc.tags, vec!["updated-tag"]);
    }

    #[test]
    fn rebuild_media_links_replaces_stale_links_and_rolls_back_invalid_sidecars() {
        let (db, dir) = setup();
        let source = create_test_png(dir.path());
        let media = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &source,
            "photo.png",
            None,
            None,
            None,
            None,
            None,
            vec![],
        )
        .unwrap();
        let first = crate::engine::post::create_post(
            db.conn(),
            dir.path(),
            "p1",
            "First",
            None,
            vec![],
            vec![],
            None,
            None,
            None,
        )
        .unwrap();
        let stale = crate::engine::post::create_post(
            db.conn(),
            dir.path(),
            "p1",
            "Stale",
            None,
            vec![],
            vec![],
            None,
            None,
            None,
        )
        .unwrap();
        crate::engine::post_media::link_media_to_post(
            db.conn(),
            dir.path(),
            "p1",
            &first.id,
            &media.id,
            0,
        )
        .unwrap();
        crate::engine::post_media::link_media_to_post(
            db.conn(),
            dir.path(),
            "p1",
            &stale.id,
            &media.id,
            1,
        )
        .unwrap();
        atomic_write_str(
            &dir.path().join(&media.sidecar_path),
            &MediaSidecar::from_media(&media, std::slice::from_ref(&first.id)).to_string(),
        )
        .unwrap();

        let report = rebuild_media_links(db.conn(), dir.path(), "p1").unwrap();
        assert_eq!(report.links, 1);
        let links = qpm::list_post_media_by_media(db.conn(), &media.id).unwrap();
        assert_eq!(links.len(), 1);
        assert_eq!(links[0].post_id, first.id);

        atomic_write_str(
            &dir.path().join(&media.sidecar_path),
            &MediaSidecar::from_media(&media, &["missing-post".into()]).to_string(),
        )
        .unwrap();
        assert!(rebuild_media_links(db.conn(), dir.path(), "p1").is_err());
        let links = qpm::list_post_media_by_media(db.conn(), &media.id).unwrap();
        assert_eq!(
            links.len(),
            1,
            "the failed repair must roll back link deletion"
        );
        assert_eq!(links[0].post_id, first.id);
    }

    #[test]
    fn replace_media_file_preserves_identity_and_regenerates_artifacts() {
        let (db, dir) = setup();
        let source = create_test_png(dir.path());
        let media = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &source,
            "photo.png",
            Some("Kept title"),
            None,
            None,
            None,
            None,
            vec!["kept".into()],
        )
        .unwrap();
        let replacement = dir.path().join("replacement.png");
        DynamicImage::new_rgb8(320, 180).save(&replacement).unwrap();
        let old_checksum = media.checksum.clone();

        let updated = replace_media_file(db.conn(), dir.path(), &media.id, &replacement)
            .unwrap()
            .unwrap();

        assert_eq!(updated.id, media.id);
        assert_eq!(updated.file_path, media.file_path);
        assert_eq!(updated.original_name, media.original_name);
        assert_eq!(updated.title, media.title);
        assert_eq!(updated.tags, media.tags);
        assert_eq!((updated.width, updated.height), (Some(320), Some(180)));
        assert_ne!(updated.checksum, old_checksum);
        assert!(!dir.path().join(format!("{}.bak", media.file_path)).exists());

        let sidecar =
            read_sidecar(&fs::read_to_string(dir.path().join(&updated.sidecar_path)).unwrap())
                .unwrap();
        assert_eq!((sidecar.width, sidecar.height), (Some(320), Some(180)));

        let prefix = &media.id[..2];
        let ai_thumb = dir
            .path()
            .join("thumbnails")
            .join(prefix)
            .join(format!("{}-ai.jpg", media.id));
        assert!(ai_thumb.is_file());
    }

    #[test]
    fn replace_media_file_is_noop_for_identical_content() {
        let (db, dir) = setup();
        let source = create_test_png(dir.path());
        let media = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &source,
            "photo.png",
            None,
            None,
            None,
            None,
            None,
            vec![],
        )
        .unwrap();
        let stored = dir.path().join(&media.file_path);
        let duplicate = dir.path().join("duplicate.png");
        fs::copy(&stored, &duplicate).unwrap();
        let established = replace_media_file(db.conn(), dir.path(), &media.id, &duplicate)
            .unwrap()
            .unwrap();
        let updated_at = established.updated_at;

        assert!(
            replace_media_file(db.conn(), dir.path(), &media.id, &stored)
                .unwrap()
                .is_none()
        );
        assert_eq!(
            qm::get_media_by_id(db.conn(), &media.id)
                .unwrap()
                .updated_at,
            updated_at
        );
    }

    #[test]
    fn import_media_at_preserves_caller_supplied_bds2_checksum() {
        let (db, dir) = setup();
        let source = create_test_png(dir.path());
        let checksum = crate::util::media_file_hash(&source).unwrap();

        let media = import_media_at(
            db.conn(),
            dir.path(),
            "p1",
            &source,
            "photo.png",
            None,
            None,
            None,
            None,
            None,
            vec![],
            Some(&checksum),
            now_unix_ms(),
        )
        .unwrap();

        assert_eq!(media.checksum.as_deref(), Some(checksum.as_str()));
    }

    #[test]
    fn delete_media_removes_everything() {
        let (db, dir) = setup();
        let source = create_test_png(dir.path());

        let media = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &source,
            "photo.png",
            None,
            None,
            None,
            None,
            None,
            vec![],
        )
        .unwrap();

        let abs_file = dir.path().join(&media.file_path);
        let abs_sidecar = dir.path().join(&media.sidecar_path);
        assert!(abs_file.exists());
        assert!(abs_sidecar.exists());

        // Delete
        delete_media(db.conn(), dir.path(), &media.id).unwrap();

        // Verify file gone
        assert!(!abs_file.exists(), "binary file should be removed");

        // Verify sidecar gone
        assert!(!abs_sidecar.exists(), "sidecar should be removed");

        // Verify DB entry gone
        assert!(qm::get_media_by_id(db.conn(), &media.id).is_err());

        // Verify thumbnails gone
        let prefix = &media.id[..2];
        for size in THUMBNAIL_SIZES {
            let ext = match size.format {
                ThumbnailFormat::Webp => "webp",
                ThumbnailFormat::Jpeg => "jpg",
            };
            let thumb = dir
                .path()
                .join("thumbnails")
                .join(prefix)
                .join(format!("{}-{}.{ext}", media.id, size.name));
            assert!(!thumb.exists(), "thumbnail {} should be removed", size.name);
        }
    }

    #[test]
    fn upsert_media_translation_writes_sidecar() {
        let (db, dir) = setup();
        let source = create_test_png(dir.path());

        let media = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &source,
            "photo.png",
            None,
            None,
            None,
            None,
            None,
            vec![],
        )
        .unwrap();

        // Create translation
        let t = upsert_media_translation(
            db.conn(),
            dir.path(),
            &media.id,
            "de",
            Some("Deutsches Foto"),
            Some("Ein Foto"),
            None,
        )
        .unwrap();

        assert_eq!(t.language, "de");
        assert_eq!(t.title.as_deref(), Some("Deutsches Foto"));

        // Verify translation sidecar file
        let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
        let abs_sidecar = dir.path().join(&sidecar_rel);
        assert!(abs_sidecar.exists(), "translation sidecar should exist");

        let content = fs::read_to_string(&abs_sidecar).unwrap();
        let sc = read_translation_sidecar(&content).unwrap();
        assert_eq!(sc.language, "de");
        assert_eq!(sc.title.as_deref(), Some("Deutsches Foto"));
    }

    #[test]
    fn delete_media_translation_removes_sidecar() {
        let (db, dir) = setup();
        let source = create_test_png(dir.path());

        let media = import_media(
            db.conn(),
            dir.path(),
            "p1",
            &source,
            "photo.png",
            None,
            None,
            None,
            None,
            None,
            vec![],
        )
        .unwrap();

        // Create translation
        upsert_media_translation(
            db.conn(),
            dir.path(),
            &media.id,
            "de",
            Some("Titel"),
            None,
            None,
        )
        .unwrap();

        let sidecar_rel = media_translation_sidecar_path(&media.file_path, "de");
        let abs_sidecar = dir.path().join(&sidecar_rel);
        assert!(abs_sidecar.exists());

        // Delete translation
        delete_media_translation(db.conn(), dir.path(), &media.id, "de").unwrap();

        // Sidecar should be gone
        assert!(
            !abs_sidecar.exists(),
            "translation sidecar should be removed"
        );

        // DB entry should be gone
        assert!(
            qmt::get_media_translation_by_media_and_language(db.conn(), &media.id, "de").is_err()
        );
    }

    #[test]
    fn rebuild_media_from_filesystem_test() {
        let (db, dir) = setup();

        // Create a fake media file and its sidecar manually
        let media_subdir = dir.path().join("media").join("2024").join("01");
        fs::create_dir_all(&media_subdir).unwrap();

        // Write a dummy binary file
        let media_file = media_subdir.join("abcdef12-test-uuid.png");
        fs::write(&media_file, b"fake-png-data").unwrap();

        // Write a canonical sidecar
        let sidecar_content = "\
---
id: abcdef12-test-uuid
originalName: \"photo.png\"
mimeType: image/png
size: 13
title: \"Rebuild Test\"
alt: \"An image\"
createdAt: 2024-01-15T12:00:00.000Z
updatedAt: 2024-01-15T12:00:00.000Z
tags: [\"test\"]
---";
        fs::write(
            media_subdir.join("abcdef12-test-uuid.png.meta"),
            sidecar_content,
        )
        .unwrap();

        // Write a translation sidecar
        let trans_sidecar_content = "\
---
translationFor: abcdef12-test-uuid
language: de
title: \"Wiederherstellungstest\"
alt: \"Ein Bild\"
---";
        fs::write(
            media_subdir.join("abcdef12-test-uuid.png.de.meta"),
            trans_sidecar_content,
        )
        .unwrap();

        // Run rebuild
        let report = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();

        assert_eq!(report.media_created, 1);
        assert_eq!(report.translations_created, 1);
        assert!(report.errors.is_empty(), "errors: {:?}", report.errors);

        // Verify media in DB
        let media = qm::get_media_by_id(db.conn(), "abcdef12-test-uuid").unwrap();
        assert_eq!(media.title.as_deref(), Some("Rebuild Test"));
        assert_eq!(media.tags, vec!["test"]);
        assert_eq!(media.original_name, "photo.png");

        // Verify translation in DB
        let trans =
            qmt::get_media_translation_by_media_and_language(db.conn(), "abcdef12-test-uuid", "de")
                .unwrap();
        assert_eq!(trans.title.as_deref(), Some("Wiederherstellungstest"));

        // Run rebuild again - should update, not create
        let report2 = rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();
        assert_eq!(report2.media_created, 0);
        assert_eq!(report2.media_updated, 1);
        assert_eq!(report2.translations_created, 0);
        assert_eq!(report2.translations_updated, 1);
    }

    #[test]
    fn rebuild_media_trusts_sidecars_without_generating_thumbnails() {
        let (db, dir) = setup();
        let media_subdir = dir.path().join("media/2024/01");
        fs::create_dir_all(&media_subdir).unwrap();
        DynamicImage::new_rgb8(100, 80)
            .save(media_subdir.join("sidecar-only.png"))
            .unwrap();
        fs::write(
            media_subdir.join("sidecar-only.png.meta"),
            "---\nid: sidecar-only\noriginalName: \"photo.png\"\nmimeType: image/png\nsize: 123\nwidth: 640\nheight: 480\ncreatedAt: 2024-01-15T12:00:00.000Z\nupdatedAt: 2024-01-15T12:00:00.000Z\ntags: []\n---",
        )
        .unwrap();

        rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap();

        let media = qm::get_media_by_id(db.conn(), "sidecar-only").unwrap();
        assert_eq!(
            (media.size, media.width, media.height),
            (123, Some(640), Some(480))
        );
        assert!(!dir.path().join("thumbnails").exists());
    }

    #[test]
    fn is_translation_sidecar_detection() {
        assert!(is_translation_sidecar("photo.jpg.de.meta"));
        assert!(is_translation_sidecar("photo.jpg.fr.meta"));
        assert!(is_translation_sidecar("uuid.png.en.meta"));
        assert!(!is_translation_sidecar("photo.jpg.meta"));
        assert!(!is_translation_sidecar("photo.meta"));
        assert!(!is_translation_sidecar("photo.jpg.123.meta"));
        assert!(!is_translation_sidecar("photo.jpg.DEU.meta"));
    }
}
