use base64::{Engine as _, engine::general_purpose::STANDARD};
use reqwest::Client;
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use zeroclaw_api::model_provider::ChatMessage;
use zeroclaw_config::schema::{MultimodalConfig, build_runtime_proxy_client_with_timeouts};

const IMAGE_MARKER_PREFIX: &str = "[IMAGE:";
const ALLOWED_IMAGE_MIME_TYPES: &[&str] = &["image/png", "image/jpeg", "image/webp", "image/gif"];

/// Per-path cache for resolved local image data URIs. Keyed by absolute
/// path; stores `(len, mtime)` for freshness checks (`(0, 0)` sentinel
/// = immutable upload). LRU evicts by both entry count and total bytes.
#[derive(Debug, Default)]
pub struct LocalImageCache {
    entries: HashMap<String, (u64, i64, String)>,
    order: std::collections::VecDeque<String>,
    bytes: usize,
}

const LOCAL_IMAGE_CACHE_MAX_ENTRIES: usize = 32;
const LOCAL_IMAGE_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;

impl LocalImageCache {
    pub fn new() -> Self {
        Self::default()
    }

    fn get(&mut self, path: &str, len: u64, mtime: i64) -> Option<&str> {
        let (cached_len, cached_mtime, _) = self.entries.get(path)?;
        let immutable = *cached_len == 0 && *cached_mtime == 0;
        let fresh = *cached_len == len && *cached_mtime == mtime;
        if !immutable && !fresh {
            return None;
        }
        if let Some(pos) = self.order.iter().position(|p| p == path) {
            let key = self.order.remove(pos).expect("position valid");
            self.order.push_back(key);
        }
        self.entries.get(path).map(|(_, _, uri)| uri.as_str())
    }

    fn insert(&mut self, path: String, len: u64, mtime: i64, data_uri: String) {
        if let Some((_, _, old)) = self.entries.remove(&path) {
            self.bytes = self.bytes.saturating_sub(old.len());
            if let Some(pos) = self.order.iter().position(|p| p == &path) {
                self.order.remove(pos);
            }
        }
        self.bytes += data_uri.len();
        self.entries.insert(path.clone(), (len, mtime, data_uri));
        self.order.push_back(path);
        while self.entries.len() > LOCAL_IMAGE_CACHE_MAX_ENTRIES
            || self.bytes > LOCAL_IMAGE_CACHE_MAX_BYTES
        {
            let Some(victim) = self.order.pop_front() else {
                break;
            };
            if let Some((_, _, uri)) = self.entries.remove(&victim) {
                self.bytes = self.bytes.saturating_sub(uri.len());
            }
        }
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

#[derive(Debug, Clone)]
pub struct PreparedMessages {
    pub messages: Vec<ChatMessage>,
    pub contains_images: bool,
}

#[derive(Debug, thiserror::Error)]
pub enum MultimodalError {
    #[error("multimodal image limit exceeded: max_images={max_images}, found={found}")]
    TooManyImages { max_images: usize, found: usize },

    #[error(
        "multimodal image size limit exceeded for '{input}': {size_bytes} bytes > {max_bytes} bytes"
    )]
    ImageTooLarge {
        input: String,
        size_bytes: usize,
        max_bytes: usize,
    },

    #[error("multimodal image MIME type is not allowed for '{input}': {mime}")]
    UnsupportedMime { input: String, mime: String },

    #[error("multimodal remote image fetch is disabled for '{input}'")]
    RemoteFetchDisabled { input: String },

    #[error("multimodal image source not found or unreadable: '{input}'")]
    ImageSourceNotFound { input: String },

    #[error("invalid multimodal image marker '{input}': {reason}")]
    InvalidMarker { input: String, reason: String },

    #[error("failed to download remote image '{input}': {reason}")]
    RemoteFetchFailed { input: String, reason: String },

    #[error("failed to read local image '{input}': {reason}")]
    LocalReadFailed { input: String, reason: String },
}

fn is_loadable_image_reference(candidate: &str) -> bool {
    candidate.starts_with('/')
        || candidate.starts_with("http://")
        || candidate.starts_with("https://")
        || candidate.starts_with("data:")
        || is_windows_path(candidate)
        || is_windows_unc_path(candidate)
}

/// Returns true for Windows-style absolute paths like `C:\…` or `D:/…`.
fn is_windows_path(candidate: &str) -> bool {
    let mut chars = candidate.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !first.is_ascii_alphabetic() {
        return false;
    }
    let Some(second) = chars.next() else {
        return false;
    };
    if second != ':' {
        return false;
    }
    matches!(chars.next(), Some('\\') | Some('/'))
}

fn is_windows_unc_path(candidate: &str) -> bool {
    let Some(rest) = candidate.strip_prefix(r"\\") else {
        return false;
    };
    if rest.starts_with('?') || rest.starts_with('.') {
        return false;
    }
    let mut parts = rest.splitn(2, ['\\', '/']);
    let server = parts.next().unwrap_or("");
    let share = parts.next().unwrap_or("");
    !server.is_empty() && !share.is_empty()
}

fn collapse_wrapped_marker(raw: &str) -> String {
    if !raw.contains('\n') && !raw.contains('\r') {
        return raw.trim().to_string();
    }
    let mut out = String::with_capacity(raw.len());
    let mut skip_ws = false;
    for ch in raw.chars() {
        if ch == '\n' || ch == '\r' {
            skip_ws = true;
            continue;
        }
        if skip_ws {
            if ch.is_whitespace() {
                continue;
            }
            skip_ws = false;
        }
        out.push(ch);
    }
    out.trim().to_string()
}

pub fn parse_image_markers(content: &str) -> (String, Vec<String>) {
    let mut refs = Vec::new();
    let mut cleaned = String::with_capacity(content.len());
    let mut cursor = 0usize;

    while let Some(rel_start) = content[cursor..].find(IMAGE_MARKER_PREFIX) {
        let start = cursor + rel_start;
        cleaned.push_str(&content[cursor..start]);

        let marker_start = start + IMAGE_MARKER_PREFIX.len();
        let Some(rel_end) = content[marker_start..].find(']') else {
            cleaned.push_str(&content[start..]);
            cursor = content.len();
            break;
        };

        let end = marker_start + rel_end;
        let candidate = collapse_wrapped_marker(&content[marker_start..end]);

        if candidate.is_empty() || !is_loadable_image_reference(&candidate) {
            // Preserve the original marker text (placeholders like
            // `[IMAGE:...]` or `[IMAGE:<path>]` should survive as prose
            // rather than triggering a loader error).
            cleaned.push_str(&content[start..=end]);
        } else {
            refs.push(candidate);
        }

        cursor = end + 1;
    }

    if cursor < content.len() {
        cleaned.push_str(&content[cursor..]);
    }

    (cleaned.trim().to_string(), refs)
}

pub fn count_image_markers(messages: &[ChatMessage]) -> usize {
    let latest_tool_indices = latest_tool_result_indices(messages);
    count_image_markers_with_latest_tool_results(messages, &latest_tool_indices)
}

fn count_image_markers_with_latest_tool_results(
    messages: &[ChatMessage],
    latest_tool_result_indices: &HashSet<usize>,
) -> usize {
    messages
        .iter()
        .enumerate()
        .filter(|(index, message)| {
            should_normalize_message_images(*index, message, latest_tool_result_indices)
        })
        .map(|(_, message)| parse_image_markers(&message.content).1.len())
        .sum()
}

pub fn contains_image_markers(messages: &[ChatMessage]) -> bool {
    count_image_markers(messages) > 0
}

pub fn count_user_image_markers(messages: &[ChatMessage]) -> usize {
    messages
        .iter()
        .filter(|message| message.role == "user" && !is_prompt_tool_result_message(message))
        .map(|message| parse_image_markers(&message.content).1.len())
        .sum()
}

pub fn count_latest_user_image_markers(messages: &[ChatMessage]) -> usize {
    messages
        .iter()
        .rev()
        .find(|message| message.role == "user" && !is_prompt_tool_result_message(message))
        .map(|message| parse_image_markers(&message.content).1.len())
        .unwrap_or(0)
}

/// Media-marker kinds this module recognizes. `IMAGE` is the only kind
/// resolved into provider content parts; [`AUDIO_MARKER_KINDS`] is the strict
/// subset degraded when a loadable payload would otherwise reach the model as
/// literal text. Both marker regexes below derive their kind alternation from
/// these consts so the strip-all and strip-audio paths cannot drift apart on
/// which kinds exist. The channel grammar (`ATTACHMENT_KINDS` in
/// `crates/zeroclaw-channels/src/util.rs`) recognizes these same kinds plus
/// `LOCATION`, which carries coordinates rather than a file reference and has
/// no provider-side handling; the two lists live in different crates
/// deliberately (providers cannot depend on channels).
const MEDIA_MARKER_KINDS: &[&str] = &[
    "IMAGE", "PHOTO", "DOCUMENT", "FILE", "VIDEO", "VOICE", "AUDIO",
];

/// Marker kinds whose loadable payload must not stay model-visible. No
/// provider resolves audio into content parts, and an audio path is not
/// otherwise actionable by the model: asked what it hears, a model handed a
/// bare path tends to fabricate having played the file. Every other kind in
/// [`MEDIA_MARKER_KINDS`] keeps its payload — `IMAGE` is resolved for vision
/// downstream, and `PHOTO`/`DOCUMENT`/`FILE`/`VIDEO` paths stay actionable
/// (file tools read them, and the channel delivery contract has the model
/// copy them into outbound reply markers), so stripping those would break
/// document and file delivery.
const AUDIO_MARKER_KINDS: &[&str] = &["VOICE", "AUDIO"];

pub fn strip_media_markers(text: &str) -> String {
    static RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
        regex::Regex::new(&format!(
            r"(?i)\[(?:{}):[^\]]*\]",
            MEDIA_MARKER_KINDS.join("|")
        ))
        .unwrap()
    });
    RE.replace_all(text, "[media attachment]").into_owned()
}

/// Matches the audio-kind markers ([`AUDIO_MARKER_KINDS`]), capturing the
/// payload for the loadable-reference check.
static AUDIO_MARKER_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
    regex::Regex::new(&format!(
        r"(?i)\[(?:{}):([^\]]*)\]",
        AUDIO_MARKER_KINDS.join("|")
    ))
    .unwrap()
});

/// Replace audio markers (`[AUDIO:...]`, `[VOICE:...]`) whose payload is a
/// *loadable* reference (absolute path, `http(s)://` URL, or `data:` URI) with
/// the same `[media attachment]` placeholder the degrade path uses, returning
/// the rewritten text and the number of markers replaced.
///
/// Non-loadable payloads are left as literal text — placeholders (`[AUDIO:...]`),
/// prose (`[AUDIO:<clip>]`), and the no-transcription note (`[Audio: attached]`)
/// are harmless and must survive — mirroring how [`parse_image_markers`]
/// preserves non-loadable `[IMAGE:...]` markers. Runs over the raw string so it
/// also cleans a marker embedded in a native tool-result JSON blob
/// (`{"content":"…[AUDIO:/clip.wav]…"}`): `[media attachment]` contains no
/// JSON-special characters, so the surrounding object stays valid.
fn strip_unplayable_audio_markers(text: &str) -> (String, usize) {
    let mut stripped = 0usize;
    let out = AUDIO_MARKER_RE.replace_all(text, |caps: &regex::Captures<'_>| {
        let payload = collapse_wrapped_marker(&caps[1]);
        if !payload.is_empty() && is_loadable_image_reference(&payload) {
            stripped += 1;
            "[media attachment]".to_string()
        } else {
            // Preserve placeholder/prose markers verbatim.
            caps[0].to_string()
        }
    });
    (out.into_owned(), stripped)
}

/// Strip loadable audio markers (see `strip_unplayable_audio_markers`)
/// across every message in `messages`, logging one degradation warning when
/// any are removed. Returns the input borrowed when no candidate marker is
/// present (the common, allocation-free path) or an owned rebuilt vector
/// otherwise.
///
/// This is the shared seam keeping a raw audio path out of provider payloads,
/// whichever route the history takes:
/// - the main iteration prep ([`prepare_messages_for_provider`], via
///   `prepare_messages_inner`), and
/// - one-shot queries that dispatch history directly without full prep (the
///   max-iteration graceful summary and the other `run_model_query` callers).
///
/// Non-audio media markers pass through untouched; see `AUDIO_MARKER_KINDS`
/// for why the split falls where it does.
pub fn sanitize_audio_markers(messages: &[ChatMessage]) -> Cow<'_, [ChatMessage]> {
    if !messages
        .iter()
        .any(|m| AUDIO_MARKER_RE.is_match(&m.content))
    {
        return Cow::Borrowed(messages);
    }

    let mut stripped = 0usize;
    let rebuilt: Vec<ChatMessage> = messages
        .iter()
        .map(|m| {
            let (content, n) = strip_unplayable_audio_markers(&m.content);
            stripped += n;
            ChatMessage {
                role: m.role.clone(),
                content,
            }
        })
        .collect();

    if stripped > 0 {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({
                    "markers_stripped": stripped,
                })),
            "multimodal: stripped unplayable audio marker(s) (AUDIO/VOICE); no provider resolves audio into content parts, so a raw path/URL was replaced with a placeholder instead of being sent to the model as text"
        );
    }

    Cow::Owned(rebuilt)
}

pub fn extract_ollama_image_payload(image_ref: &str) -> Option<String> {
    if image_ref.starts_with("data:") {
        let comma_idx = image_ref.find(',')?;
        let (_, payload) = image_ref.split_at(comma_idx + 1);
        let payload = payload.trim();
        if payload.is_empty() {
            None
        } else {
            Some(payload.to_string())
        }
    } else {
        Some(image_ref.trim().to_string()).filter(|value| !value.is_empty())
    }
}

pub(crate) fn is_prompt_tool_result_message(message: &ChatMessage) -> bool {
    message.role == "user" && message.content.trim_start().starts_with("[Tool results]")
}

fn is_tool_result_carrier(message: &ChatMessage) -> bool {
    message.role == "tool" || is_prompt_tool_result_message(message)
}

fn latest_tool_result_indices(messages: &[ChatMessage]) -> HashSet<usize> {
    let mut indices = HashSet::new();
    let Some((last_index, last_message)) = messages.iter().enumerate().next_back() else {
        return indices;
    };

    if is_prompt_tool_result_message(last_message) {
        indices.insert(last_index);
        return indices;
    }

    if last_message.role == "tool" {
        for (index, message) in messages.iter().enumerate().rev() {
            if message.role != "tool" {
                break;
            }
            indices.insert(index);
        }
    }

    indices
}

fn should_normalize_message_images(
    index: usize,
    message: &ChatMessage,
    latest_tool_result_indices: &HashSet<usize>,
) -> bool {
    if is_tool_result_carrier(message) {
        return latest_tool_result_indices.contains(&index);
    }

    message.role == "user"
}

fn stripped_image_marker_text(content: &str) -> String {
    let (cleaned, refs) = parse_image_markers(content);
    if refs.is_empty() {
        return content.to_string();
    }

    if cleaned.trim().is_empty() {
        "[image removed from history]".to_string()
    } else {
        cleaned
    }
}

fn strip_tool_result_image_markers(message: &ChatMessage) -> ChatMessage {
    if !message.content.contains(IMAGE_MARKER_PREFIX) {
        return message.clone();
    }

    if message.role == "tool"
        && let Ok(serde_json::Value::Object(mut obj)) =
            serde_json::from_str::<serde_json::Value>(&message.content)
        && let Some(serde_json::Value::String(inner)) = obj.get("content").cloned()
    {
        let stripped = stripped_image_marker_text(&inner);
        if stripped == inner {
            return message.clone();
        }

        obj.insert("content".to_string(), serde_json::Value::String(stripped));
        return ChatMessage {
            role: message.role.clone(),
            content: serde_json::Value::Object(obj).to_string(),
        };
    }

    ChatMessage {
        role: message.role.clone(),
        content: stripped_image_marker_text(&message.content),
    }
}

fn replay_message_without_stale_tool_images(
    index: usize,
    message: &ChatMessage,
    latest_tool_result_indices: &HashSet<usize>,
) -> ChatMessage {
    if is_tool_result_carrier(message) && !latest_tool_result_indices.contains(&index) {
        strip_tool_result_image_markers(message)
    } else {
        message.clone()
    }
}

async fn normalize_native_tool_result_json(
    content: &str,
    config: &MultimodalConfig,
    max_bytes: usize,
    remote_client: &Client,
    ctx: &ImageNormalizeCtx<'_>,
    cache: Option<&mut LocalImageCache>,
) -> Option<(String, bool)> {
    let Ok(serde_json::Value::Object(mut obj)) = serde_json::from_str::<serde_json::Value>(content)
    else {
        return None;
    };

    let Some(serde_json::Value::String(inner)) = obj.get("content").cloned() else {
        return None;
    };

    let (cleaned_text, refs) = parse_image_markers(&inner);
    if refs.is_empty() {
        return None;
    }

    let normalized =
        normalize_image_references(&refs, config, max_bytes, remote_client, ctx, cache).await;
    let new_inner = compose_multimodal_content(
        &cleaned_text,
        &normalized.data_uris,
        normalized.skipped_count,
        refs.len(),
    );
    obj.insert("content".to_string(), serde_json::Value::String(new_inner));

    Some((
        serde_json::Value::Object(obj).to_string(),
        !normalized.data_uris.is_empty(),
    ))
}

pub async fn prepare_messages_for_provider(
    messages: &[ChatMessage],
    config: &MultimodalConfig,
) -> anyhow::Result<PreparedMessages> {
    prepare_messages_inner(messages, config, None).await
}

/// Like [`prepare_messages_for_provider`] but reuses a [`LocalImageCache`]
/// across calls so each unique local image file is read from disk at most
/// once per session (or once per modification for mutable files).
pub async fn prepare_messages_for_provider_cached(
    messages: &[ChatMessage],
    config: &MultimodalConfig,
    cache: &mut LocalImageCache,
) -> anyhow::Result<PreparedMessages> {
    prepare_messages_inner(messages, config, Some(cache)).await
}

async fn prepare_messages_inner(
    messages: &[ChatMessage],
    config: &MultimodalConfig,
    mut cache: Option<&mut LocalImageCache>,
) -> anyhow::Result<PreparedMessages> {
    // Strip loadable audio markers before any provider sees the history. Left
    // in place, an audio path reaches the model as literal text and fails
    // silently — the model typically hallucinates having played the file,
    // which is worse than an explicit degradation. `[IMAGE:...]` markers are
    // handled by the normalization below; other media kinds keep their
    // payloads for delivery. The shared seam borrows the input untouched when
    // no audio marker is present, so the common hot path stays allocation-free.
    let sanitized = sanitize_audio_markers(messages);
    let messages: &[ChatMessage] = &sanitized;

    let (max_images, max_image_size_mb) = config.effective_limits();
    let max_bytes = max_image_size_mb.saturating_mul(1024 * 1024);

    let latest_tool_indices = latest_tool_result_indices(messages);
    let total_images = count_image_markers_with_latest_tool_results(messages, &latest_tool_indices);

    if total_images == 0 {
        return Ok(PreparedMessages {
            messages: messages
                .iter()
                .enumerate()
                .map(|(index, message)| {
                    replay_message_without_stale_tool_images(index, message, &latest_tool_indices)
                })
                .collect(),
            contains_images: false,
        });
    }

    // Normalize every image marker first, then enforce the per-request image
    // cap further below based only on images that *successfully* normalize.
    // Trimming the oldest images *before* normalization is unsafe: a newer
    // image ref that fails to load would evict an older valid one that could
    // still have been sent (see `skipped_images_do_not_consume_image_budget`).
    // The post-normalization cap keeps the most recent successful images and
    // prevents conversations from sticking once the cumulative count crosses
    // the threshold, so no pre-normalization trim is needed here.
    let remote_client = build_runtime_proxy_client_with_timeouts("model_provider.ollama", 30, 10);
    let latest_tool_indices = latest_tool_result_indices(messages);

    let mut normalized_messages = Vec::with_capacity(messages.len());
    let mut has_successful_images = false;
    for (index, message) in messages.iter().enumerate() {
        if !should_normalize_message_images(index, message, &latest_tool_indices) {
            normalized_messages.push(replay_message_without_stale_tool_images(
                index,
                message,
                &latest_tool_indices,
            ));
            continue;
        }

        if message.role == "tool"
            && let Some((prepared, contains_images)) = normalize_native_tool_result_json(
                &message.content,
                config,
                max_bytes,
                &remote_client,
                &ImageNormalizeCtx {
                    message_index: index,
                    role: &message.role,
                },
                cache.as_deref_mut(),
            )
            .await
        {
            normalized_messages.push(ChatMessage {
                role: message.role.clone(),
                content: prepared,
            });
            has_successful_images |= contains_images;
            continue;
        }

        let (cleaned_text, refs) = parse_image_markers(&message.content);
        if refs.is_empty() {
            normalized_messages.push(message.clone());
            continue;
        }

        let normalized = normalize_image_references(
            &refs,
            config,
            max_bytes,
            &remote_client,
            &ImageNormalizeCtx {
                message_index: index,
                role: &message.role,
            },
            cache.as_deref_mut(),
        )
        .await;
        let content = compose_multimodal_content(
            &cleaned_text,
            &normalized.data_uris,
            normalized.skipped_count,
            refs.len(),
        );
        has_successful_images |= !normalized.data_uris.is_empty();
        normalized_messages.push(ChatMessage {
            role: message.role.clone(),
            content,
        });
    }

    // Apply age-based trimming when configured: strip images from user messages
    // older than `max_image_turns` turns back from the end of history.
    // `max_image_turns == 0` means disabled — no age trimming.
    let age_trimmed = if config.max_image_turns > 0 {
        let before = count_image_markers(&normalized_messages);
        let trimmed = trim_images_by_age(&normalized_messages, config.max_image_turns);
        let after = count_image_markers(&trimmed);
        if after < before {
            ::zeroclaw_log::record!(
                INFO,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                    .with_attrs(::serde_json::json!({
                        "max_image_turns": config.max_image_turns,
                        "images_before": before,
                        "images_after": after,
                        "images_dropped": before - after,
                    })),
                "multimodal: age-trimmed old images from conversation history"
            );
        }
        trimmed
    } else {
        normalized_messages
    };

    // Apply the per-request image cap after normalization so failed image refs
    // do not consume budget and evict older images that could still be sent.
    let capped_messages = if has_successful_images && count_image_markers(&age_trimmed) > max_images
    {
        ::zeroclaw_log::record!(
            WARN,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                .with_attrs(::serde_json::json!({
                    "images_after_normalization": count_image_markers(&age_trimmed),
                    "max_images": max_images,
                })),
            "multimodal: post-normalization image cap exceeded — trimming oldest images"
        );
        trim_old_images(&age_trimmed, max_images)
    } else {
        age_trimmed
    };

    Ok(PreparedMessages {
        contains_images: count_image_markers(&capped_messages) > 0,
        messages: capped_messages,
    })
}
fn trim_images_by_age(messages: &[ChatMessage], max_turns: usize) -> Vec<ChatMessage> {
    // Count user messages from the end to find the cutoff index.
    let mut user_turn_count = 0usize;
    let mut cutoff = 0usize; // messages at index < cutoff are "too old"
    for (i, m) in messages.iter().enumerate().rev() {
        if m.role == "user" {
            user_turn_count += 1;
            if user_turn_count > max_turns {
                // Everything up to and including this index is too old.
                cutoff = i + 1;
                break;
            }
        }
    }

    if cutoff == 0 {
        return messages.to_vec();
    }

    messages
        .iter()
        .enumerate()
        .map(|(i, m)| {
            if i < cutoff && m.role == "user" {
                let (cleaned, refs) = parse_image_markers(&m.content);
                if refs.is_empty() {
                    return m.clone();
                }
                let text = if cleaned.trim().is_empty() {
                    "[image removed from history]".to_string()
                } else {
                    cleaned
                };
                ChatMessage {
                    role: m.role.clone(),
                    content: text,
                }
            } else {
                m.clone()
            }
        })
        .collect()
}

/// Strip image markers from older messages (oldest first) until total image
/// count is within `max_images`. Keeps the text content of each message.
fn trim_old_images(messages: &[ChatMessage], max_images: usize) -> Vec<ChatMessage> {
    let latest_tool_indices = latest_tool_result_indices(messages);
    // Find which messages (by index) contain images, oldest first.
    let image_positions: Vec<(usize, usize)> = messages
        .iter()
        .enumerate()
        .filter(|(index, message)| {
            should_normalize_message_images(*index, message, &latest_tool_indices)
        })
        .filter_map(|(i, m)| {
            let count = parse_image_markers(&m.content).1.len();
            if count > 0 { Some((i, count)) } else { None }
        })
        .collect();

    // Determine how many images to drop (from the oldest messages).
    let total: usize = image_positions.iter().map(|(_, c)| c).sum();
    let mut to_drop = total.saturating_sub(max_images);

    // Collect indices of messages whose images should be stripped.
    let mut strip_indices = std::collections::HashSet::new();
    for &(idx, count) in &image_positions {
        if to_drop == 0 {
            break;
        }
        strip_indices.insert(idx);
        to_drop = to_drop.saturating_sub(count);
    }

    messages
        .iter()
        .enumerate()
        .map(|(i, m)| {
            if strip_indices.contains(&i) {
                let (cleaned, _) = parse_image_markers(&m.content);
                let text = if cleaned.trim().is_empty() {
                    "[image removed from history]".to_string()
                } else {
                    cleaned
                };
                ChatMessage {
                    role: m.role.clone(),
                    content: text,
                }
            } else {
                replay_message_without_stale_tool_images(i, m, &latest_tool_indices)
            }
        })
        .collect()
}

fn compose_multimodal_message(text: &str, data_uris: &[String]) -> String {
    let mut content = String::new();
    let trimmed = text.trim();

    if !trimmed.is_empty() {
        content.push_str(trimmed);
        content.push_str("\n\n");
    }

    for (index, data_uri) in data_uris.iter().enumerate() {
        if index > 0 {
            content.push('\n');
        }
        content.push_str(IMAGE_MARKER_PREFIX);
        content.push_str(data_uri);
        content.push(']');
    }

    content
}

struct NormalizedImageReferences {
    data_uris: Vec<String>,
    skipped_count: usize,
}

/// Context attached to image-skip log events so callers can be identified.
struct ImageNormalizeCtx<'a> {
    /// Zero-based index of this message in the conversation history.
    message_index: usize,
    /// Role of the message containing the image reference.
    role: &'a str,
}

async fn normalize_image_references(
    refs: &[String],
    config: &MultimodalConfig,
    max_bytes: usize,
    remote_client: &Client,
    ctx: &ImageNormalizeCtx<'_>,
    mut cache: Option<&mut LocalImageCache>,
) -> NormalizedImageReferences {
    let mut data_uris = Vec::with_capacity(refs.len());
    let mut skipped_count = 0usize;

    for reference in refs {
        match normalize_image_reference(
            reference,
            config,
            max_bytes,
            remote_client,
            cache.as_deref_mut(),
        )
        .await
        {
            Ok(data_uri) => data_uris.push(data_uri),
            Err(error) => {
                skipped_count += 1;
                let error_reason = multimodal_error_reason(&error);
                // Truncate the raw reference so we don't dump a full base64
                // payload into the log, but keep enough to identify the source.
                let marker_preview: String = reference.chars().take(120).collect();
                let error_kind = multimodal_error_kind(&error);
                let attrs = ::serde_json::json!({
                    "message_index": ctx.message_index,
                    "message_role": ctx.role,
                    "source_kind": image_reference_kind(reference),
                    "error_kind": error_kind,
                    "reason": error_reason.as_deref().unwrap_or(""),
                    "marker_preview": marker_preview,
                });
                let is_tool_role = ctx.role == "tool";
                let is_recoverable_load_failure = matches!(
                    error_kind,
                    "image_source_not_found"
                        | "local_read_failed"
                        | "remote_fetch_failed"
                        | "invalid_marker"
                );
                if is_tool_role && is_recoverable_load_failure {
                    ::zeroclaw_log::record!(
                        DEBUG,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_attrs(attrs),
                        "skipping multimodal marker in tool result (likely not a real attachment)"
                    );
                } else {
                    ::zeroclaw_log::record!(
                        WARN,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                            .with_attrs(attrs),
                        "skipping multimodal image that could not be loaded"
                    );
                }
            }
        }
    }

    NormalizedImageReferences {
        data_uris,
        skipped_count,
    }
}

fn compose_multimodal_content(
    text: &str,
    data_uris: &[String],
    skipped_count: usize,
    total_refs: usize,
) -> String {
    if skipped_count == 0 {
        return compose_multimodal_message(text, data_uris);
    }

    let text_with_note = append_skipped_image_note(text, skipped_count, total_refs);
    if data_uris.is_empty() {
        text_with_note.trim().to_string()
    } else {
        compose_multimodal_message(&text_with_note, data_uris)
    }
}

fn append_skipped_image_note(text: &str, skipped_count: usize, total_refs: usize) -> String {
    if skipped_count == 0 {
        return text.to_string();
    }

    // This note is model-facing provider context, not direct localized UI text.
    let note = if skipped_count == total_refs {
        format!("{skipped_count} attached image(s) could not be loaded")
    } else {
        format!("{skipped_count} of {total_refs} attached image(s) could not be loaded")
    };

    let trimmed = text.trim();
    if trimmed.is_empty() {
        format!("Note: {note}.")
    } else {
        format!("{trimmed}\n\nNote: {note}.")
    }
}

fn image_reference_kind(reference: &str) -> &'static str {
    if reference.starts_with("data:") {
        "data"
    } else if reference.starts_with("http://") || reference.starts_with("https://") {
        "remote"
    } else {
        "local"
    }
}

fn multimodal_error_kind(error: &anyhow::Error) -> &'static str {
    match error.downcast_ref::<MultimodalError>() {
        Some(MultimodalError::TooManyImages { .. }) => "too_many_images",
        Some(MultimodalError::ImageTooLarge { .. }) => "image_too_large",
        Some(MultimodalError::UnsupportedMime { .. }) => "unsupported_mime",
        Some(MultimodalError::RemoteFetchDisabled { .. }) => "remote_fetch_disabled",
        Some(MultimodalError::ImageSourceNotFound { .. }) => "image_source_not_found",
        Some(MultimodalError::InvalidMarker { .. }) => "invalid_marker",
        Some(MultimodalError::RemoteFetchFailed { .. }) => "remote_fetch_failed",
        Some(MultimodalError::LocalReadFailed { .. }) => "local_read_failed",
        None => "unknown",
    }
}

fn multimodal_error_reason(error: &anyhow::Error) -> Option<String> {
    match error.downcast_ref::<MultimodalError>() {
        Some(MultimodalError::InvalidMarker { input, reason })
        | Some(MultimodalError::RemoteFetchFailed { input, reason })
        | Some(MultimodalError::LocalReadFailed { input, reason }) => {
            Some(reason.replace(input, "<source>"))
        }
        _ => None,
    }
}

async fn normalize_image_reference(
    source: &str,
    config: &MultimodalConfig,
    max_bytes: usize,
    remote_client: &Client,
    cache: Option<&mut LocalImageCache>,
) -> anyhow::Result<String> {
    if source.starts_with("data:") {
        return normalize_data_uri(source, max_bytes);
    }

    if source.starts_with("http://") || source.starts_with("https://") {
        if !config.allow_remote_fetch {
            return Err(MultimodalError::RemoteFetchDisabled {
                input: source.to_string(),
            }
            .into());
        }

        return normalize_remote_image(source, max_bytes, remote_client).await;
    }

    match cache {
        Some(c) => normalize_local_image_cached(source, max_bytes, c).await,
        None => normalize_local_image(source, max_bytes).await,
    }
}

fn normalize_data_uri(source: &str, max_bytes: usize) -> anyhow::Result<String> {
    let Some(comma_idx) = source.find(',') else {
        return Err(MultimodalError::InvalidMarker {
            input: source.to_string(),
            reason: "expected data URI payload".to_string(),
        }
        .into());
    };

    let header = &source[..comma_idx];
    let payload = source[comma_idx + 1..].trim();

    if !header.contains(";base64") {
        return Err(MultimodalError::InvalidMarker {
            input: source.to_string(),
            reason: "only base64 data URIs are supported".to_string(),
        }
        .into());
    }

    let mime = header
        .trim_start_matches("data:")
        .split(';')
        .next()
        .unwrap_or_default()
        .trim()
        .to_ascii_lowercase();

    validate_mime(source, &mime)?;

    let decoded = STANDARD
        .decode(payload)
        .map_err(|error| MultimodalError::InvalidMarker {
            input: source.to_string(),
            reason: format!("invalid base64 payload: {error}"),
        })?;

    validate_size(source, decoded.len(), max_bytes)?;

    Ok(format!("data:{mime};base64,{}", STANDARD.encode(decoded)))
}

async fn normalize_remote_image(
    source: &str,
    max_bytes: usize,
    remote_client: &Client,
) -> anyhow::Result<String> {
    let response = remote_client.get(source).send().await.map_err(|error| {
        MultimodalError::RemoteFetchFailed {
            input: source.to_string(),
            reason: error.to_string(),
        }
    })?;

    let status = response.status();
    if !status.is_success() {
        return Err(MultimodalError::RemoteFetchFailed {
            input: source.to_string(),
            reason: format!("HTTP {status}"),
        }
        .into());
    }

    if let Some(content_length) = response.content_length() {
        let content_length = usize::try_from(content_length).unwrap_or(usize::MAX);
        validate_size(source, content_length, max_bytes)?;
    }

    let content_type = response
        .headers()
        .get(reqwest::header::CONTENT_TYPE)
        .and_then(|value| value.to_str().ok())
        .map(ToString::to_string);

    let bytes = response
        .bytes()
        .await
        .map_err(|error| MultimodalError::RemoteFetchFailed {
            input: source.to_string(),
            reason: error.to_string(),
        })?;

    validate_size(source, bytes.len(), max_bytes)?;

    let mime = detect_mime(None, bytes.as_ref(), content_type.as_deref()).ok_or_else(|| {
        MultimodalError::UnsupportedMime {
            input: source.to_string(),
            mime: "unknown".to_string(),
        }
    })?;

    validate_mime(source, &mime)?;

    Ok(format!("data:{mime};base64,{}", STANDARD.encode(bytes)))
}

async fn normalize_local_image(source: &str, max_bytes: usize) -> anyhow::Result<String> {
    let path = Path::new(source);
    if !path.exists() || !path.is_file() {
        return Err(MultimodalError::ImageSourceNotFound {
            input: source.to_string(),
        }
        .into());
    }

    let metadata =
        tokio::fs::metadata(path)
            .await
            .map_err(|error| MultimodalError::LocalReadFailed {
                input: source.to_string(),
                reason: error.to_string(),
            })?;

    validate_size(
        source,
        usize::try_from(metadata.len()).unwrap_or(usize::MAX),
        max_bytes,
    )?;

    let bytes = tokio::fs::read(path)
        .await
        .map_err(|error| MultimodalError::LocalReadFailed {
            input: source.to_string(),
            reason: error.to_string(),
        })?;

    validate_size(source, bytes.len(), max_bytes)?;

    let mime =
        detect_mime(Some(path), &bytes, None).ok_or_else(|| MultimodalError::UnsupportedMime {
            input: source.to_string(),
            mime: "unknown".to_string(),
        })?;

    validate_mime(source, &mime)?;

    Ok(format!("data:{mime};base64,{}", STANDARD.encode(bytes)))
}

/// Cache-aware local image loader. On a hit (path + metadata unchanged) returns
/// the stored data URI without touching the filesystem. Files under `/uploads/`
/// are content-addressed and treated as immutable — checked once, never re-read.
async fn normalize_local_image_cached(
    source: &str,
    max_bytes: usize,
    cache: &mut LocalImageCache,
) -> anyhow::Result<String> {
    let path = Path::new(source);
    if !path.exists() || !path.is_file() {
        return Err(MultimodalError::ImageSourceNotFound {
            input: source.to_string(),
        }
        .into());
    }

    let metadata =
        tokio::fs::metadata(path)
            .await
            .map_err(|error| MultimodalError::LocalReadFailed {
                input: source.to_string(),
                reason: error.to_string(),
            })?;

    let file_len = metadata.len();
    let is_immutable = source.contains("/uploads/");
    let mtime: i64 = if is_immutable {
        0
    } else {
        metadata
            .modified()
            .ok()
            .and_then(|t| {
                t.duration_since(std::time::UNIX_EPOCH)
                    .ok()
                    .map(|d| d.as_secs() as i64)
            })
            .unwrap_or(0)
    };
    let cache_len = if is_immutable { 0 } else { file_len };

    if let Some(cached) = cache.get(source, cache_len, mtime) {
        return Ok(cached.to_string());
    }

    validate_size(
        source,
        usize::try_from(file_len).unwrap_or(usize::MAX),
        max_bytes,
    )?;

    let bytes = tokio::fs::read(path)
        .await
        .map_err(|error| MultimodalError::LocalReadFailed {
            input: source.to_string(),
            reason: error.to_string(),
        })?;

    validate_size(source, bytes.len(), max_bytes)?;

    let mime =
        detect_mime(Some(path), &bytes, None).ok_or_else(|| MultimodalError::UnsupportedMime {
            input: source.to_string(),
            mime: "unknown".to_string(),
        })?;

    validate_mime(source, &mime)?;

    let data_uri = format!("data:{mime};base64,{}", STANDARD.encode(&bytes));
    cache.insert(source.to_string(), cache_len, mtime, data_uri.clone());
    Ok(data_uri)
}

fn validate_size(source: &str, size_bytes: usize, max_bytes: usize) -> anyhow::Result<()> {
    if size_bytes > max_bytes {
        return Err(MultimodalError::ImageTooLarge {
            input: source.to_string(),
            size_bytes,
            max_bytes,
        }
        .into());
    }

    Ok(())
}

fn validate_mime(source: &str, mime: &str) -> anyhow::Result<()> {
    if ALLOWED_IMAGE_MIME_TYPES.contains(&mime) {
        return Ok(());
    }

    Err(MultimodalError::UnsupportedMime {
        input: source.to_string(),
        mime: mime.to_string(),
    }
    .into())
}

fn detect_mime(
    path: Option<&Path>,
    bytes: &[u8],
    header_content_type: Option<&str>,
) -> Option<String> {
    if let Some(header_mime) = header_content_type.and_then(normalize_content_type) {
        return Some(header_mime);
    }

    if let Some(path) = path
        && let Some(ext) = path.extension().and_then(|value| value.to_str())
        && let Some(mime) = mime_from_extension(ext)
    {
        return Some(mime.to_string());
    }

    mime_from_magic(bytes).map(ToString::to_string)
}

fn normalize_content_type(content_type: &str) -> Option<String> {
    let mime = content_type.split(';').next()?.trim().to_ascii_lowercase();
    if mime.is_empty() { None } else { Some(mime) }
}

fn mime_from_extension(ext: &str) -> Option<&'static str> {
    match ext.to_ascii_lowercase().as_str() {
        "png" => Some("image/png"),
        "jpg" | "jpeg" => Some("image/jpeg"),
        "webp" => Some("image/webp"),
        "gif" => Some("image/gif"),
        "bmp" => Some("image/bmp"),
        _ => None,
    }
}

fn mime_from_magic(bytes: &[u8]) -> Option<&'static str> {
    if bytes.len() >= 8 && bytes.starts_with(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']) {
        return Some("image/png");
    }

    if bytes.len() >= 3 && bytes.starts_with(&[0xff, 0xd8, 0xff]) {
        return Some("image/jpeg");
    }

    if bytes.len() >= 6 && (bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) {
        return Some("image/gif");
    }

    if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
        return Some("image/webp");
    }

    if bytes.len() >= 2 && bytes.starts_with(b"BM") {
        return Some("image/bmp");
    }

    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn strip_media_markers_replaces_image_local_path() {
        let input = "Look at [IMAGE:/zeroclaw-data/workspace/telegram_files/photo_1.jpg]";
        assert_eq!(strip_media_markers(input), "Look at [media attachment]");
    }

    #[test]
    fn strip_media_markers_replaces_image_data_uri() {
        let input = "Inline [IMAGE:data:image/png;base64,abcd]";
        assert_eq!(strip_media_markers(input), "Inline [media attachment]");
    }

    #[test]
    fn strip_media_markers_replaces_all_supported_kinds() {
        // Mirrors `ATTACHMENT_KINDS` in
        // `crates/zeroclaw-channels/src/util.rs`, which is the source of
        // truth for which marker spellings inbound channels can produce.
        let input = "[IMAGE:/a.jpg] [PHOTO:/b.jpg] [DOCUMENT:/c.pdf] [FILE:/d.zip] [VIDEO:/e.mp4] [VOICE:/f.ogg] [AUDIO:/g.wav]";
        let expected = "[media attachment] [media attachment] [media attachment] [media attachment] [media attachment] [media attachment] [media attachment]";
        assert_eq!(strip_media_markers(input), expected);
    }

    #[test]
    fn strip_media_markers_is_case_insensitive() {
        // Channel parsers uppercase the kind before comparing, so by the time
        // a marker reaches conversation history it is normally upper-case —
        // but accept lower/mixed case too so we don't depend on that
        // invariant downstream.
        let input = "[image:/a.jpg] [Photo:/b.jpg] [video:/c.mp4]";
        let expected = "[media attachment] [media attachment] [media attachment]";
        assert_eq!(strip_media_markers(input), expected);
    }

    #[test]
    fn strip_media_markers_leaves_plain_text_untouched() {
        let input = "No markers here, just text with [brackets] and (parens).";
        assert_eq!(strip_media_markers(input), input);
    }

    #[test]
    fn strip_media_markers_preserves_unrelated_brackets() {
        // Markers that don't match the media kinds are left alone.
        let input = "Use [TODO:foo] and [NOTE:bar] but replace [IMAGE:/x.jpg]";
        assert_eq!(
            strip_media_markers(input),
            "Use [TODO:foo] and [NOTE:bar] but replace [media attachment]"
        );
    }

    // ── loadable audio markers degrade; other media kinds keep their paths ──

    #[test]
    fn strip_unplayable_audio_markers_replaces_loadable_audio_path() {
        let (out, n) = strip_unplayable_audio_markers("hear this [AUDIO:/tmp/clip.wav] now");
        assert_eq!(out, "hear this [media attachment] now");
        assert_eq!(n, 1);
    }

    #[test]
    fn strip_unplayable_audio_markers_degrades_audio_kinds_only() {
        // The delivery contract: DOCUMENT/FILE/VIDEO/PHOTO paths stay
        // model-visible so the agent can hand them to file tools or copy them
        // into outbound reply markers; only the audio kinds degrade.
        let input = "[PHOTO:/a.jpg] [DOCUMENT:/b.pdf] [FILE:/c.zip] [VIDEO:/d.mp4] [VOICE:/e.ogg] [AUDIO:/f.wav]";
        let (out, n) = strip_unplayable_audio_markers(input);
        assert_eq!(
            out,
            "[PHOTO:/a.jpg] [DOCUMENT:/b.pdf] [FILE:/c.zip] [VIDEO:/d.mp4] [media attachment] [media attachment]"
        );
        assert_eq!(n, 2);
    }

    #[test]
    fn audio_marker_kinds_is_subset_of_media_marker_kinds() {
        for kind in AUDIO_MARKER_KINDS {
            assert!(
                MEDIA_MARKER_KINDS.contains(kind),
                "audio kind {kind} missing from the full marker vocabulary"
            );
        }
    }

    #[test]
    fn strip_unplayable_audio_markers_leaves_image_marker_untouched() {
        // `[IMAGE:...]` is handled by `parse_image_markers`; the audio
        // stripper must never touch it (that would drop a resolvable image).
        let (out, n) = strip_unplayable_audio_markers("[IMAGE:/a.png] and [AUDIO:/b.wav]");
        assert_eq!(out, "[IMAGE:/a.png] and [media attachment]");
        assert_eq!(n, 1);
    }

    #[test]
    fn strip_unplayable_audio_markers_preserves_non_loadable_payloads() {
        // Placeholders, prose, a bare filename, and the no-transcription
        // `[Audio: attached]` note are harmless literal text — keep them.
        for input in [
            "[AUDIO:...]",
            "[VOICE:<clip>]",
            "[Audio: attached]",
            "[AUDIO:example.wav]",
        ] {
            let (out, n) = strip_unplayable_audio_markers(input);
            assert_eq!(out, input, "should preserve non-loadable marker: {input}");
            assert_eq!(
                n, 0,
                "non-loadable marker must not count as stripped: {input}"
            );
        }
    }

    #[test]
    fn strip_unplayable_audio_markers_is_case_insensitive() {
        let (out, n) = strip_unplayable_audio_markers("[Audio:/tmp/clip.wav]");
        assert_eq!(out, "[media attachment]");
        assert_eq!(n, 1);
    }

    #[test]
    fn strip_unplayable_audio_markers_handles_data_uri_and_url() {
        let (out, n) = strip_unplayable_audio_markers(
            "[VOICE:data:audio/ogg;base64,AAAA] and [AUDIO:https://x/y.mp3]",
        );
        assert_eq!(out, "[media attachment] and [media attachment]");
        assert_eq!(n, 2);
    }

    #[tokio::test]
    async fn prepare_messages_strips_tool_result_audio_marker() {
        // The reported failure: a tool result surfaces an audio path. With no
        // images in history, prep must still strip the marker so the raw
        // filesystem path never reaches the provider as literal text.
        let history = vec![
            ChatMessage::user("call the tool and tell me what you hear"),
            ChatMessage::tool("[AUDIO:/tmp/clip.wav] recorded 3:00 PM"),
        ];
        let cfg = MultimodalConfig::default();
        let prepared = prepare_messages_for_provider(&history, &cfg).await.unwrap();
        let tool_msg = prepared
            .messages
            .iter()
            .find(|m| m.role == "tool")
            .expect("tool message survives prep");
        assert!(
            !tool_msg.content.contains("/tmp/clip.wav"),
            "raw audio path must not reach the provider: {}",
            tool_msg.content
        );
        assert!(tool_msg.content.contains("[media attachment]"));
        assert!(!prepared.contains_images);
    }

    #[tokio::test]
    async fn prepare_messages_preserves_document_marker_for_delivery() {
        // A tool result that surfaces a document path must reach the provider
        // intact: the agent copies that path into an outbound reply marker to
        // deliver the file, and file tools read it on request. Only the audio
        // kinds degrade.
        let history = vec![
            ChatMessage::user("send me the report"),
            ChatMessage::tool(
                "[DOCUMENT:/workspace/report.pdf] generated, and [AUDIO:/tmp/note.wav]",
            ),
        ];
        let cfg = MultimodalConfig::default();
        let prepared = prepare_messages_for_provider(&history, &cfg).await.unwrap();
        let tool_msg = prepared
            .messages
            .iter()
            .find(|m| m.role == "tool")
            .expect("tool message survives prep");
        assert!(
            tool_msg
                .content
                .contains("[DOCUMENT:/workspace/report.pdf]"),
            "document path must stay model-visible for delivery: {}",
            tool_msg.content
        );
        assert!(
            !tool_msg.content.contains("/tmp/note.wav"),
            "audio path alongside it must still degrade: {}",
            tool_msg.content
        );
    }

    #[tokio::test]
    async fn prepare_messages_strips_audio_but_keeps_image_marker() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("shot.png");
        std::fs::write(&path, [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']).unwrap();
        let history = vec![ChatMessage::user(format!(
            "look [IMAGE:{}] and hear [AUDIO:/tmp/clip.wav]",
            path.display()
        ))];
        let cfg = MultimodalConfig::default();
        let prepared = prepare_messages_for_provider(&history, &cfg).await.unwrap();
        let content = &prepared.messages[0].content;
        assert!(
            !content.contains("/tmp/clip.wav"),
            "audio path must be stripped: {content}"
        );
        assert!(content.contains("[media attachment]"));
        // The image marker is still normalized to a data URI alongside it.
        assert!(prepared.contains_images, "image still inlined: {content}");
    }

    #[test]
    fn parse_image_markers_extracts_multiple_markers() {
        let input = "Check this [IMAGE:/tmp/a.png] and this [IMAGE:https://example.com/b.jpg]";
        let (cleaned, refs) = parse_image_markers(input);

        assert_eq!(cleaned, "Check this  and this");
        assert_eq!(refs.len(), 2);
        assert_eq!(refs[0], "/tmp/a.png");
        assert_eq!(refs[1], "https://example.com/b.jpg");
    }

    #[test]
    fn is_windows_unc_path_accepts_shares_and_rejects_others() {
        assert!(is_windows_unc_path(r"\\server\share\pic.png"));
        assert!(is_windows_unc_path(r"\\server\share\sub\pic.png"));
        // Verbatim / device prefixes are not plain shares.
        assert!(!is_windows_unc_path(r"\\?\C:\Users\me\a.png"));
        assert!(!is_windows_unc_path(r"\\?\UNC\server\share\a.png"));
        assert!(!is_windows_unc_path(r"\\.\PhysicalDrive0"));
        // Needs both a server and a further segment.
        assert!(!is_windows_unc_path(r"\\server"));
        assert!(!is_windows_unc_path(r"\\"));
        // Non-UNC inputs.
        assert!(!is_windows_unc_path("/home/me/a.png"));
        assert!(!is_windows_unc_path(r"C:\Users\me\a.png"));
    }

    #[test]
    fn parse_image_markers_extracts_unc_path() {
        // Regression for theWindows follow-up: `image_info` unwraps the
        // verbatim-UNC prefix (`\\?\UNC\…`) to a plain `\\server\share\…`
        // path, which must be treated as a loadable image reference (not left
        // as literal text) so the image reaches vision models.
        let input = r"File: [IMAGE:\\server\share\pic.png]";
        let (_, refs) = parse_image_markers(input);
        assert_eq!(refs.len(), 1, "UNC marker should be extracted as a ref");
        assert_eq!(refs[0], r"\\server\share\pic.png");
    }

    #[test]
    fn validate_mime_rejects_bmp_but_accepts_provider_supported_types() {
        for mime in ["image/png", "image/jpeg", "image/webp", "image/gif"] {
            assert!(
                validate_mime("src", mime).is_ok(),
                "{mime} should be allowed"
            );
        }
        // BMP is detectable but unsupported by vision providers; it must be
        // rejected here so it never breaks the whole provider request.
        let err = validate_mime("src", "image/bmp").unwrap_err();
        assert_eq!(multimodal_error_kind(&err), "unsupported_mime");
    }

    #[test]
    fn parse_image_markers_collapses_line_wrapped_path() {
        // Terminal-wrapped paste: a long path split across two rows with
        // leading indentation should be recovered into the original path.
        let input = "from the logs whether the agent emits\n  [IMAGE:/home/zeroclaw_user/.zeroclaw/workspace/signal_i\n  nbound/attachment.jpg] (which the\n  channel resolves)";
        let (_, refs) = parse_image_markers(input);
        assert_eq!(refs.len(), 1);
        assert_eq!(
            refs[0],
            "/home/zeroclaw_user/.zeroclaw/workspace/signal_inbound/attachment.jpg"
        );
    }

    #[test]
    fn parse_image_markers_leaves_placeholder_markers_as_literal_text() {
        // Illustrative markdown like `[IMAGE:...]` or `[IMAGE:<path>]`
        // (e.g. in agent-authored prose the user quotes back) is not a
        // loadable reference and must stay as literal text — otherwise the
        // multimodal loader errors every turn the conversation replays.
        let input = "example: `[IMAGE:...]` or `[IMAGE:<path>]` or `[IMAGE:example.png]`";
        let (cleaned, refs) = parse_image_markers(input);
        assert!(
            refs.is_empty(),
            "no placeholder should be treated as a loadable ref, got: {refs:?}"
        );
        assert!(cleaned.contains("[IMAGE:...]"));
        assert!(cleaned.contains("[IMAGE:<path>]"));
        assert!(cleaned.contains("[IMAGE:example.png]"));
    }

    #[test]
    fn parse_image_markers_preserves_spaces_in_path() {
        // Spaces within a single-line marker are legitimate (paths can
        // contain spaces) and must survive unchanged.
        let input = "look at [IMAGE:/tmp/my photos/beetle.png] please";
        let (_, refs) = parse_image_markers(input);
        assert_eq!(refs.len(), 1);
        assert_eq!(refs[0], "/tmp/my photos/beetle.png");
    }

    #[test]
    fn parse_image_markers_keeps_invalid_empty_marker() {
        let input = "hello [IMAGE:] world";
        let (cleaned, refs) = parse_image_markers(input);

        assert_eq!(cleaned, "hello [IMAGE:] world");
        assert!(refs.is_empty());
    }

    #[tokio::test]
    async fn prepare_messages_normalizes_local_image_to_data_uri() {
        let temp = tempfile::tempdir().unwrap();
        let image_path = temp.path().join("sample.png");

        // Minimal PNG signature bytes are enough for MIME detection.
        std::fs::write(
            &image_path,
            [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'],
        )
        .unwrap();

        let messages = vec![ChatMessage::user(format!(
            "Please inspect this screenshot [IMAGE:{}]",
            image_path.display()
        ))];

        let prepared = prepare_messages_for_provider(&messages, &MultimodalConfig::default())
            .await
            .unwrap();

        assert!(prepared.contains_images);
        assert_eq!(prepared.messages.len(), 1);

        let (cleaned, refs) = parse_image_markers(&prepared.messages[0].content);
        assert_eq!(cleaned, "Please inspect this screenshot");
        assert_eq!(refs.len(), 1);
        assert!(refs[0].starts_with("data:image/png;base64,"));
    }

    #[tokio::test]
    async fn prepare_messages_normalizes_tool_message_local_image_to_data_uri() {
        let temp = tempfile::tempdir().unwrap();
        let image_path = temp.path().join("tool-sample.png");

        std::fs::write(
            &image_path,
            [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'],
        )
        .unwrap();

        let messages = vec![ChatMessage::tool(format!(
            "<tool_result name=\"image_gen\">\nGenerated image [IMAGE:{}]\n</tool_result>",
            image_path.display()
        ))];

        let prepared = prepare_messages_for_provider(&messages, &MultimodalConfig::default())
            .await
            .unwrap();

        assert!(prepared.contains_images);
        assert_eq!(prepared.messages.len(), 1);
        assert_eq!(prepared.messages[0].role, "tool");

        let (cleaned, refs) = parse_image_markers(&prepared.messages[0].content);
        assert!(cleaned.contains("<tool_result name=\"image_gen\">"));
        assert!(cleaned.contains("Generated image"));
        assert_eq!(refs.len(), 1);
        assert!(refs[0].starts_with("data:image/png;base64,"));
    }

    #[tokio::test]
    async fn prepare_messages_preserves_native_tool_result_json_shape() {
        let temp = tempfile::tempdir().unwrap();
        let image_path = temp.path().join("native-tool-result.png");
        std::fs::write(
            &image_path,
            [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'],
        )
        .unwrap();

        let native_tool_content = serde_json::json!({
            "tool_call_id": "tc1",
            "content": format!("see attached [IMAGE:{}]", image_path.display().to_string()),
        })
        .to_string();

        let messages = vec![ChatMessage::tool(native_tool_content)];

        let prepared = prepare_messages_for_provider(&messages, &MultimodalConfig::default())
            .await
            .expect("preparation should succeed for native tool-result JSON");

        assert!(prepared.contains_images);
        assert_eq!(prepared.messages.len(), 1);
        assert_eq!(prepared.messages[0].role, "tool");

        let value: serde_json::Value = serde_json::from_str(&prepared.messages[0].content)
            .expect("prepared tool message must remain valid JSON");

        assert_eq!(
            value.get("tool_call_id").and_then(|v| v.as_str()),
            Some("tc1"),
            "tool_call_id must survive multimodal preprocessing unchanged"
        );

        let inner = value
            .get("content")
            .and_then(|v| v.as_str())
            .expect("content must remain a JSON string");
        assert!(
            inner.contains("see attached"),
            "surrounding text in tool content should survive normalization"
        );
        assert!(
            inner.contains("data:image/png;base64,"),
            "local image path inside tool content should be rewritten to a data URI"
        );
        assert!(
            !inner.contains("native-tool-result.png"),
            "raw local path must not leak after normalization"
        );
    }

    #[tokio::test]
    async fn prepare_messages_preserves_native_tool_json_when_image_is_skipped() {
        let native_tool_content = serde_json::json!({
            "tool_call_id": "tc1",
            "content": "generated screenshot [IMAGE:https://example.com/missing.png]",
        })
        .to_string();

        let prepared = prepare_messages_for_provider(
            &[ChatMessage::tool(native_tool_content)],
            &MultimodalConfig::default(),
        )
        .await
        .expect("skipped native tool image should not fail message preparation");

        assert!(!prepared.contains_images);
        assert_eq!(prepared.messages.len(), 1);

        let value: serde_json::Value = serde_json::from_str(&prepared.messages[0].content)
            .expect("native tool result must remain valid JSON");
        assert_eq!(
            value.get("tool_call_id").and_then(|v| v.as_str()),
            Some("tc1")
        );

        let inner = value
            .get("content")
            .and_then(|v| v.as_str())
            .expect("content should remain a JSON string");
        assert!(inner.contains("generated screenshot"));
        assert!(inner.contains("1 attached image(s) could not be loaded"));
        assert!(!inner.contains("[IMAGE:"));
        assert!(!inner.contains("https://example.com/missing.png"));
    }

    #[tokio::test]
    async fn prepare_messages_preserves_native_tool_json_with_mixed_images() {
        let temp = tempfile::tempdir().unwrap();
        let image_path = temp.path().join("mixed-native-tool-result.png");
        std::fs::write(
            &image_path,
            [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'],
        )
        .unwrap();

        let native_tool_content = serde_json::json!({
            "tool_call_id": "tc1",
            "content": format!(
                "generated [IMAGE:{}] and [IMAGE:https://example.com/missing.png]",
                image_path.display()
            ),
        })
        .to_string();

        let prepared = prepare_messages_for_provider(
            &[ChatMessage::tool(native_tool_content)],
            &MultimodalConfig::default(),
        )
        .await
        .expect("valid native tool image should survive while bad ref is skipped");

        assert!(prepared.contains_images);
        assert_eq!(prepared.messages.len(), 1);

        let value: serde_json::Value = serde_json::from_str(&prepared.messages[0].content)
            .expect("native tool result must remain valid JSON");
        assert_eq!(
            value.get("tool_call_id").and_then(|v| v.as_str()),
            Some("tc1")
        );

        let inner = value
            .get("content")
            .and_then(|v| v.as_str())
            .expect("content should remain a JSON string");
        assert!(inner.contains("generated"));
        assert!(inner.contains("data:image/png;base64,"));
        assert!(inner.contains("1 of 2 attached image(s) could not be loaded"));
        assert!(!inner.contains("mixed-native-tool-result.png"));
        assert!(!inner.contains("https://example.com/missing.png"));
    }

    #[tokio::test]
    async fn prepare_messages_strips_stale_native_tool_result_images() {
        let temp = tempfile::tempdir().unwrap();
        let image_path = temp.path().join("stale-native-tool-result.png");
        std::fs::write(
            &image_path,
            [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'],
        )
        .unwrap();

        let native_tool_content = serde_json::json!({
            "tool_call_id": "tc1",
            "content": format!("generated screenshot [IMAGE:{}]", image_path.display().to_string()),
        })
        .to_string();

        let messages = vec![
            ChatMessage::tool(native_tool_content),
            ChatMessage {
                role: "assistant".to_string(),
                content: "I generated the screenshot.".to_string(),
            },
            ChatMessage::user("What happened next?".to_string()),
        ];

        let prepared = prepare_messages_for_provider(&messages, &MultimodalConfig::default())
            .await
            .expect("preparation should strip stale tool images without loading them");

        assert!(
            !prepared.contains_images,
            "stale tool-result images should not keep the request in vision mode"
        );

        let value: serde_json::Value = serde_json::from_str(&prepared.messages[0].content)
            .expect("stale native tool result should remain valid JSON");
        assert_eq!(
            value.get("tool_call_id").and_then(|v| v.as_str()),
            Some("tc1")
        );

        let inner = value
            .get("content")
            .and_then(|v| v.as_str())
            .expect("content should remain a JSON string");
        assert!(inner.contains("generated screenshot"));
        assert!(!inner.contains("[IMAGE:"));
        assert!(!inner.contains("data:image"));
        assert!(!inner.contains("stale-native-tool-result.png"));
    }

    #[tokio::test]
    async fn prepare_messages_strips_stale_prompt_tool_result_images() {
        let temp = tempfile::tempdir().unwrap();
        let image_path = temp.path().join("stale-prompt-tool-result.png");
        std::fs::write(
            &image_path,
            [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'],
        )
        .unwrap();

        let messages = vec![
            ChatMessage::user(format!(
                "[Tool results]\n<tool_result name=\"image_gen\">Generated [IMAGE:{}]</tool_result>",
                image_path.display()
            )),
            ChatMessage {
                role: "assistant".to_string(),
                content: "I generated the screenshot.".to_string(),
            },
            ChatMessage::user("Continue.".to_string()),
        ];

        let prepared = prepare_messages_for_provider(&messages, &MultimodalConfig::default())
            .await
            .expect("preparation should strip stale prompt-mode tool images");

        assert!(!prepared.contains_images);
        assert!(prepared.messages[0].content.contains("[Tool results]"));
        assert!(prepared.messages[0].content.contains("Generated"));
        assert!(!prepared.messages[0].content.contains("[IMAGE:"));
        assert!(!prepared.messages[0].content.contains("data:image"));
        assert!(
            !prepared.messages[0]
                .content
                .contains("stale-prompt-tool-result.png")
        );
    }

    #[tokio::test]
    async fn prepare_messages_strips_stale_tool_image_while_normalizing_current_user_image() {
        let temp = tempfile::tempdir().unwrap();
        let stale_path = temp.path().join("stale-tool-result.png");
        let fresh_path = temp.path().join("fresh-user-image.png");
        let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
        std::fs::write(&stale_path, png).unwrap();
        std::fs::write(&fresh_path, png).unwrap();

        let native_tool_content = serde_json::json!({
            "tool_call_id": "tc1",
            "content": format!("generated screenshot [IMAGE:{}]", stale_path.display().to_string()),
        })
        .to_string();

        let messages = vec![
            ChatMessage::tool(native_tool_content),
            ChatMessage {
                role: "assistant".to_string(),
                content: "I generated the screenshot.".to_string(),
            },
            ChatMessage::user(format!(
                "Now inspect this [IMAGE:{}]",
                fresh_path.display().to_string()
            )),
        ];

        let prepared = prepare_messages_for_provider(&messages, &MultimodalConfig::default())
            .await
            .expect("preparation should strip stale tool images and normalize current user image");

        assert!(prepared.contains_images);

        let value: serde_json::Value = serde_json::from_str(&prepared.messages[0].content)
            .expect("stale native tool result should remain valid JSON");
        let inner = value
            .get("content")
            .and_then(|v| v.as_str())
            .expect("content should remain a JSON string");
        assert!(inner.contains("generated screenshot"));
        assert!(!inner.contains("[IMAGE:"));
        assert!(!inner.contains("data:image"));
        assert!(!inner.contains("stale-tool-result.png"));

        let (cleaned, refs) = parse_image_markers(&prepared.messages[2].content);
        assert_eq!(cleaned, "Now inspect this");
        assert_eq!(refs.len(), 1);
        assert!(refs[0].starts_with("data:image/png;base64,"));
        assert!(
            !prepared.messages[2]
                .content
                .contains("fresh-user-image.png")
        );
    }

    #[test]
    fn count_image_markers_ignores_stale_tool_results() {
        let messages = vec![
            ChatMessage::tool("[IMAGE:/tmp/stale-tool.png]\nGenerated".to_string()),
            ChatMessage {
                role: "assistant".to_string(),
                content: "Done.".to_string(),
            },
            ChatMessage::user("Next question".to_string()),
        ];

        assert_eq!(count_image_markers(&messages), 0);

        let messages = vec![
            ChatMessage::user("Create an image".to_string()),
            ChatMessage::tool("[IMAGE:/tmp/latest-tool.png]\nGenerated".to_string()),
        ];

        assert_eq!(count_image_markers(&messages), 1);
    }

    #[test]
    fn count_latest_user_image_markers_scopes_to_newest_user_message() {
        // No user messages at all -> zero.
        assert_eq!(count_latest_user_image_markers(&[]), 0);

        // The newest user message carries the image -> counted (the user just
        // sent it; the vision router surfaces a capability error).
        let just_sent = vec![
            ChatMessage::user("hi".to_string()),
            ChatMessage {
                role: "assistant".to_string(),
                content: "hello".to_string(),
            },
            ChatMessage::user("look at this [IMAGE:/tmp/a.png]".to_string()),
        ];
        assert_eq!(count_latest_user_image_markers(&just_sent), 1);

        // An earlier user message carried an image, but the newest user message
        // is plain text -> zero. This is the poison-prevention case: the carried
        // over marker must NOT keep re-triggering the capability error.
        let carried_over = vec![
            ChatMessage::user("look at this [IMAGE:/tmp/a.png]".to_string()),
            ChatMessage::user("what is WAL?".to_string()),
        ];
        assert_eq!(count_latest_user_image_markers(&carried_over), 0);
        // The history-wide count still sees the carried-over marker, which is
        // why the router must distinguish the two.
        assert_eq!(count_user_image_markers(&carried_over), 1);

        // A trailing tool-result carrier does not mask the real latest user
        // message (its markers are not user-sent and must not be counted here).
        let trailing_tool_result = vec![
            ChatMessage::user("inspect [IMAGE:/tmp/a.png]".to_string()),
            ChatMessage::tool("[IMAGE:/tmp/tool.png]\nGenerated".to_string()),
        ];
        assert_eq!(count_latest_user_image_markers(&trailing_tool_result), 1);
    }

    #[tokio::test]
    async fn prepare_messages_trims_excess_images_from_older_messages() {
        // 3 messages, each with 1 image — max is 2.
        // The oldest message's image should be stripped.
        let messages = vec![
            ChatMessage::user("[IMAGE:/tmp/old.png]\nOld caption".to_string()),
            ChatMessage::user("[IMAGE:/tmp/mid.png]\nMid caption".to_string()),
            ChatMessage::user("[IMAGE:/tmp/new.png]\nNew caption".to_string()),
        ];

        // Should not error — instead trims oldest. (Will error on
        // normalize_image_reference for the surviving images since
        // /tmp/mid.png and /tmp/new.png don't exist, but the trimming
        // itself should succeed.)
        let trimmed = trim_old_images(&messages, 2);
        assert_eq!(trimmed.len(), 3);

        // Oldest message should have image stripped
        let (_, refs0) = parse_image_markers(&trimmed[0].content);
        assert!(refs0.is_empty(), "oldest image should be stripped");
        assert!(trimmed[0].content.contains("Old caption"));

        // Newer messages keep their images
        let (_, refs1) = parse_image_markers(&trimmed[1].content);
        assert_eq!(refs1.len(), 1);
        let (_, refs2) = parse_image_markers(&trimmed[2].content);
        assert_eq!(refs2.len(), 1);
    }

    #[test]
    fn trim_old_images_replaces_image_only_message() {
        // A message with only an image and no text should get a placeholder.
        let messages = vec![
            ChatMessage::user("[IMAGE:/tmp/old.png]".to_string()),
            ChatMessage::user("[IMAGE:/tmp/new.png]\nKeep this".to_string()),
        ];

        let trimmed = trim_old_images(&messages, 1);
        assert_eq!(trimmed[0].content, "[image removed from history]");
        assert!(trimmed[1].content.contains("[IMAGE:/tmp/new.png]"));
    }

    #[test]
    fn trim_old_images_multi_image_message_stripped_as_unit() {
        // A single message has 3 images. We need to drop 2 to reach max=1.
        // But trimming works at message granularity — the entire message gets
        // stripped (all 3 images removed), which over-trims to 0. The newest
        // message (text-only) is untouched.
        let messages = vec![
            ChatMessage::user(
                "[IMAGE:/tmp/a.png]\n[IMAGE:/tmp/b.png]\n[IMAGE:/tmp/c.png]\nThree pics"
                    .to_string(),
            ),
            ChatMessage::user("Just text, no images".to_string()),
        ];

        let trimmed = trim_old_images(&messages, 1);
        assert_eq!(trimmed.len(), 2);
        // All images in the first message are gone, but text remains
        let (_, refs0) = parse_image_markers(&trimmed[0].content);
        assert!(refs0.is_empty());
        assert!(trimmed[0].content.contains("Three pics"));
        // Second message unchanged
        assert_eq!(trimmed[1].content, "Just text, no images");
    }

    #[test]
    fn trim_old_images_skips_assistant_messages() {
        // Assistant messages with image markers should not be counted or stripped.
        let messages = vec![
            ChatMessage {
                role: "assistant".to_string(),
                content: "[IMAGE:/tmp/assistant.png]\nAssistant generated".to_string(),
            },
            ChatMessage::user("[IMAGE:/tmp/user1.png]\nFirst".to_string()),
            ChatMessage::user("[IMAGE:/tmp/user2.png]\nSecond".to_string()),
        ];

        let trimmed = trim_old_images(&messages, 1);
        // Assistant message untouched (not counted toward limit)
        assert!(trimmed[0].content.contains("[IMAGE:/tmp/assistant.png]"));
        // Oldest user image stripped
        let (_, refs1) = parse_image_markers(&trimmed[1].content);
        assert!(refs1.is_empty());
        assert!(trimmed[1].content.contains("First"));
        // Newest user image kept
        let (_, refs2) = parse_image_markers(&trimmed[2].content);
        assert_eq!(refs2.len(), 1);
    }

    #[test]
    fn trim_old_images_counts_latest_tool_messages() {
        let messages = vec![
            ChatMessage::user("[IMAGE:/tmp/user-old.png]\nOldest".to_string()),
            ChatMessage::tool("[IMAGE:/tmp/tool-new.png]\nGenerated".to_string()),
        ];

        let trimmed = trim_old_images(&messages, 1);
        let (_, refs0) = parse_image_markers(&trimmed[0].content);
        assert!(refs0.is_empty(), "oldest user image should be stripped");
        assert!(trimmed[0].content.contains("Oldest"));

        let (_, refs1) = parse_image_markers(&trimmed[1].content);
        assert_eq!(refs1.len(), 1);
    }

    #[test]
    fn trim_old_images_no_trimming_when_under_limit() {
        let messages = vec![
            ChatMessage::user("[IMAGE:/tmp/a.png]\nCaption A".to_string()),
            ChatMessage::user("[IMAGE:/tmp/b.png]\nCaption B".to_string()),
        ];

        let trimmed = trim_old_images(&messages, 5);
        // Nothing should change — both images are under the limit
        assert_eq!(trimmed[0].content, messages[0].content);
        assert_eq!(trimmed[1].content, messages[1].content);
    }

    #[test]
    fn trim_old_images_no_trimming_when_exactly_at_limit() {
        let messages = vec![
            ChatMessage::user("[IMAGE:/tmp/a.png]\nA".to_string()),
            ChatMessage::user("[IMAGE:/tmp/b.png]\nB".to_string()),
        ];

        let trimmed = trim_old_images(&messages, 2);
        assert_eq!(trimmed[0].content, messages[0].content);
        assert_eq!(trimmed[1].content, messages[1].content);
    }

    #[test]
    fn trim_old_images_empty_messages() {
        let trimmed = trim_old_images(&[], 4);
        assert!(trimmed.is_empty());
    }

    #[test]
    fn trim_old_images_interleaved_roles() {
        // Realistic conversation: user sends image, assistant replies, user sends
        // another image, etc. Only user messages should be candidates for trimming.
        let messages = vec![
            ChatMessage::user("[IMAGE:/tmp/1.png]\nLook at this".to_string()),
            ChatMessage {
                role: "assistant".to_string(),
                content: "I see a photo.".to_string(),
            },
            ChatMessage::user("[IMAGE:/tmp/2.png]\nWhat about this?".to_string()),
            ChatMessage {
                role: "assistant".to_string(),
                content: "That's a chart.".to_string(),
            },
            ChatMessage::user("[IMAGE:/tmp/3.png]\nAnd this one".to_string()),
        ];

        let trimmed = trim_old_images(&messages, 2);
        assert_eq!(trimmed.len(), 5);
        // Oldest user image stripped
        let (_, refs0) = parse_image_markers(&trimmed[0].content);
        assert!(refs0.is_empty());
        assert!(trimmed[0].content.contains("Look at this"));
        // Assistant messages untouched
        assert_eq!(trimmed[1].content, "I see a photo.");
        assert_eq!(trimmed[3].content, "That's a chart.");
        // Two newest user images kept
        let (_, refs2) = parse_image_markers(&trimmed[2].content);
        assert_eq!(refs2.len(), 1);
        let (_, refs4) = parse_image_markers(&trimmed[4].content);
        assert_eq!(refs4.len(), 1);
    }

    #[test]
    fn trim_old_images_strips_multiple_oldest_messages() {
        // 5 user images, max 1 — should strip the first 4 messages' images.
        let messages: Vec<ChatMessage> = (1..=5)
            .map(|i| ChatMessage::user(format!("[IMAGE:/tmp/{i}.png]\nCaption {i}")))
            .collect();

        let trimmed = trim_old_images(&messages, 1);
        assert_eq!(trimmed.len(), 5);
        for (i, msg) in trimmed.iter().enumerate().take(4) {
            let (_, refs) = parse_image_markers(&msg.content);
            assert!(refs.is_empty(), "message {i} should have images stripped");
            assert!(msg.content.contains(&format!("Caption {}", i + 1)));
        }
        // Only the last message keeps its image
        let (_, refs_last) = parse_image_markers(&trimmed[4].content);
        assert_eq!(refs_last.len(), 1);
    }

    #[tokio::test]
    async fn prepare_messages_trims_then_normalizes_surviving_images() {
        // End-to-end: 3 images, max 2. After trimming the oldest, the two
        // surviving images should be normalized (base64-encoded) successfully.
        let temp = tempfile::tempdir().unwrap();
        let mut paths = Vec::new();
        for name in ["old.png", "mid.png", "new.png"] {
            let p = temp.path().join(name);
            // Minimal valid PNG (1x1 white pixel)
            let png_data = [
                0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
                0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
                0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
                0x77, 0x53, 0xDE, // 1x1 RGB
                0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, // IDAT chunk
                0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21,
                0xBC, 0x33, // IDAT data + CRC
                0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, // IEND chunk
                0xAE, 0x42, 0x60, 0x82,
            ];
            std::fs::write(&p, png_data).unwrap();
            paths.push(p);
        }

        let messages = vec![
            ChatMessage::user(format!("[IMAGE:{}]\nOld", paths[0].display().to_string())),
            ChatMessage::user(format!("[IMAGE:{}]\nMid", paths[1].display().to_string())),
            ChatMessage::user(format!("[IMAGE:{}]\nNew", paths[2].display().to_string())),
        ];

        let config = MultimodalConfig {
            max_images: 2,
            max_image_size_mb: 5,
            allow_remote_fetch: false,
            ..Default::default()
        };

        let result = prepare_messages_for_provider(&messages, &config)
            .await
            .expect("should succeed after trimming");

        assert!(result.contains_images);
        assert_eq!(result.messages.len(), 3);
        // First message should have image stripped, text preserved
        assert!(!result.messages[0].content.contains("data:image"));
        assert!(result.messages[0].content.contains("Old"));
        // Second and third should have base64-encoded images
        assert!(result.messages[1].content.contains("data:image"));
        assert!(result.messages[2].content.contains("data:image"));
    }

    #[tokio::test]
    async fn prepare_messages_caps_to_newest_successful_images() {
        let temp = tempfile::tempdir().unwrap();
        // Minimal valid PNG (1x1 RGB pixel).
        let png_data = [
            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
            0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
            0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC,
            0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
        ];

        // Nine distinct valid image files across nine user messages, max 4.
        let mut messages = Vec::new();
        for i in 0..9 {
            let p = temp.path().join(format!("img{i}.png"));
            std::fs::write(&p, png_data).unwrap();
            messages.push(ChatMessage::user(format!(
                "[IMAGE:{}]\nImage {i}",
                p.display()
            )));
        }

        let config = MultimodalConfig {
            max_images: 4,
            max_image_size_mb: 5,
            allow_remote_fetch: false,
            max_image_turns: 0, // disable age-based trimming to isolate the cap
            ..Default::default()
        };

        let result = prepare_messages_for_provider(&messages, &config)
            .await
            .expect("should succeed");

        // Output is capped to exactly max_images...
        let surviving = result
            .messages
            .iter()
            .filter(|m| m.content.contains("data:image"))
            .count();
        assert_eq!(surviving, 4, "output should keep exactly max_images");

        // ...and it is the newest four that survive; the oldest five are stripped.
        for (i, m) in result.messages.iter().enumerate() {
            if i < 5 {
                assert!(
                    !m.content.contains("data:image"),
                    "oldest message {i} should be capped out"
                );
                assert!(m.content.contains(&format!("Image {i}")));
            } else {
                assert!(
                    m.content.contains("data:image"),
                    "newest message {i} should survive the cap"
                );
            }
        }
    }

    #[tokio::test]
    async fn prepare_messages_skips_remote_url_when_disabled() {
        let messages = vec![ChatMessage::user(
            "Look [IMAGE:https://example.com/img.png]".to_string(),
        )];

        let result = prepare_messages_for_provider(&messages, &MultimodalConfig::default())
            .await
            .expect("disabled remote image should be skipped");

        assert!(!result.contains_images);
        assert_eq!(result.messages.len(), 1);
        assert!(result.messages[0].content.contains("Look"));
        assert!(
            result.messages[0]
                .content
                .contains("1 attached image(s) could not be loaded")
        );
        assert!(
            !result.messages[0]
                .content
                .contains("https://example.com/img.png")
        );
    }

    #[tokio::test]
    async fn prepare_messages_skips_oversized_local_image() {
        let temp = tempfile::tempdir().unwrap();
        let image_path = temp.path().join("big.png");

        let bytes = vec![0u8; 1024 * 1024 + 1];
        std::fs::write(&image_path, bytes).unwrap();

        let messages = vec![ChatMessage::user(format!(
            "[IMAGE:{}]",
            image_path.display()
        ))];
        let config = MultimodalConfig {
            max_images: 4,
            max_image_size_mb: 1,
            allow_remote_fetch: false,
            ..Default::default()
        };

        let result = prepare_messages_for_provider(&messages, &config)
            .await
            .expect("oversized local image should be skipped");

        assert!(!result.contains_images);
        assert_eq!(result.messages.len(), 1);
        assert!(
            result.messages[0]
                .content
                .contains("1 attached image(s) could not be loaded")
        );
        assert!(
            !result.messages[0]
                .content
                .contains(image_path.to_string_lossy().as_ref())
        );
    }

    #[tokio::test]
    async fn prepare_messages_keeps_successful_images_when_some_are_skipped() {
        let temp = tempfile::tempdir().unwrap();
        let image_path = temp.path().join("ok.png");
        std::fs::write(
            &image_path,
            [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'],
        )
        .unwrap();

        let messages = vec![ChatMessage::user(format!(
            "Look [IMAGE:{}] and [IMAGE:https://example.com/missing.png]",
            image_path.display()
        ))];

        let result = prepare_messages_for_provider(&messages, &MultimodalConfig::default())
            .await
            .expect("valid local image should survive while remote image is skipped");

        assert!(result.contains_images);
        assert!(
            result.messages[0]
                .content
                .contains("data:image/png;base64,")
        );
        assert!(
            result.messages[0]
                .content
                .contains("1 of 2 attached image(s) could not be loaded")
        );
        assert!(
            !result.messages[0]
                .content
                .contains("https://example.com/missing.png")
        );
    }

    #[tokio::test]
    async fn skipped_images_do_not_consume_image_budget() {
        let temp = tempfile::tempdir().unwrap();
        let image_path = temp.path().join("older-valid.png");
        std::fs::write(
            &image_path,
            [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'],
        )
        .unwrap();

        let messages = vec![
            ChatMessage::user(format!(
                "Older valid image [IMAGE:{}]",
                image_path.display()
            )),
            ChatMessage::user(
                "Newer broken image [IMAGE:https://example.com/missing.png]".to_string(),
            ),
        ];
        let config = MultimodalConfig {
            max_images: 1,
            max_image_size_mb: 5,
            allow_remote_fetch: false,
            ..Default::default()
        };

        let result = prepare_messages_for_provider(&messages, &config)
            .await
            .expect("broken image should not evict an older valid image");

        assert!(result.contains_images);
        assert!(
            result.messages[0]
                .content
                .contains("data:image/png;base64,")
        );
        assert!(result.messages[1].content.contains("Newer broken image"));
        assert!(
            result.messages[1]
                .content
                .contains("1 attached image(s) could not be loaded")
        );
        assert!(
            !result.messages[1]
                .content
                .contains("https://example.com/missing.png")
        );
    }

    #[test]
    fn extract_ollama_image_payload_supports_data_uris() {
        let payload = extract_ollama_image_payload("data:image/png;base64,abcd==")
            .expect("payload should be extracted");
        assert_eq!(payload, "abcd==");
    }

    #[test]
    fn parse_image_markers_strips_markers_leaving_caption() {
        let input = "[IMAGE:/tmp/photo.jpg]\n\nDescribe this screenshot";
        let (cleaned, refs) = parse_image_markers(input);
        assert_eq!(cleaned, "Describe this screenshot");
        assert_eq!(refs.len(), 1);
        assert_eq!(refs[0], "/tmp/photo.jpg");
    }

    #[test]
    fn parse_image_markers_image_only_message_becomes_empty() {
        let input = "[IMAGE:/tmp/photo.jpg]";
        let (cleaned, refs) = parse_image_markers(input);
        assert!(
            cleaned.is_empty(),
            "expected empty string, got: {cleaned:?}"
        );
        assert_eq!(refs.len(), 1);
    }
}
