//! Shared helper for resolving `[Image #N: <path>]` placeholders into
//! image bytes.
//!
//! Both the TUI ([`kigi_tui::prompt_images`]) and the server-side
//! ingestion path ([`crate::session::acp_session`]) need to recover image
//! bytes when a placeholder lacks an attached `PastedImage` /
//! `ContentBlock::Image` — e.g. a paste from a previous session's
//! prompt, a session reload, or a synthetic re-render. The two sides
//! share one canonical loader so the validation rules (extension
//! allowlist, prefix allowlist, size cap, image-decoder validation,
//! aggregate-bytes cap) cannot drift.
//!
//! ## Threat model
//!
//! The placeholder path is **user-controlled** but the user does not
//! explicitly opt in to reading any arbitrary file — they paste a chat
//! transcript fragment and the agent may resurrect it across sessions.
//! To stop the placeholder mechanism from becoming a generic file
//! exfiltration sink, the loader is intentionally conservative:
//!
//! * Canonicalises every candidate path (resolves `..` and symlinks).
//! * Asserts the canonical target lives under an explicit prefix
//!   allowlist (workspace cwd + a small set of common user-image
//!   directories under `$HOME`; never the whole `$HOME`). See
//!   [`default_allowed_prefixes`].
//! * Asserts the extension is in [`ALLOWED_IMAGE_EXTENSIONS`].
//! * Routes the bytes through the `image` crate's full header parser
//!   ([`image::ImageReader::with_guessed_format`] +
//!   `into_dimensions`) so magic-byte forgery — a PNG-prefix file
//!   followed by arbitrary content — is rejected. See
//!   [`PlaceholderLoadError::NotAnImage`].
//! * Rejects any canonical path containing a known sensitive-bundle
//!   subtree (`.photoslibrary/`, `.musiclibrary/`, etc.) even when the
//!   parent prefix is in the allowlist.
//! * Enforces a per-image byte cap, a per-prompt placeholder count
//!   cap, and a per-prompt aggregate-bytes cap so a single prompt
//!   cannot trigger huge sequential syscall chains or memory spikes.
//!
//! Wire format: `[Image #<n>: <absolute_path>]` — the producer is
//! [`kigi_tui::prompt_images::display_text`]. The shape of this
//! placeholder is part of the chat-history contract — do NOT change
//! it. The regex requires the literal `": "` separator that the
//! producer always emits; see [`extract_placeholders`].
//!
//! ## `file://` URI convention
//!
//! Both the TUI's `prompt_images::build_content_blocks_with_workspace`
//! and the server-side recovery in [`recover_orphan_placeholders`]
//! emit `file://{canonical.display()}` URIs **without** percent-encoding
//! the path. This deviates from RFC 3986 (a path with spaces should be
//! `%20`-encoded) but it is internally consistent across producer and
//! consumer: [`canonical_from_file_uri`] parses inbound URIs using both
//! the relaxed unencoded form **and** percent-decoded form so dedup
//! works against either convention. Do **not** add percent-encoding on
//! one side without also doing it on the other — past-issue: producer
//! / consumer asymmetry breaks dedup.

use std::path::{Path, PathBuf};
use std::sync::LazyLock;

use regex::Regex;

/// Maximum size of a single placeholder-loaded image. Matches the TUI's
/// `MAX_SEND_BYTES` (50 MB) so a path that loads on the TUI side cannot
/// be silently rejected by the server-side fallback.
pub const MAX_PLACEHOLDER_IMAGE_BYTES: usize = 50_000_000;

/// Maximum number of placeholders the loader will process per call.
/// Caps **on-disk loads** at 16 per prompt; the regex scan itself is
/// unbounded but linear in input length and short-circuits via
/// [`Iterator::take`] before `filter_map` runs.
pub const MAX_PLACEHOLDERS_PER_PROMPT: usize = 16;

/// Per-prompt aggregate-bytes cap across recovered placeholder images.
/// Prevents 16 × 50 MB worst-case RSS spikes on memory-constrained
/// runners.
pub const MAX_PLACEHOLDER_AGGREGATE_BYTES: usize = 200 * 1024 * 1024;

/// `_meta` key under which an attached image's `[Image #N]` display number
/// is recorded on its ACP image block, so the server can resolve
/// `[Image #N]` tokens to the right attachment by number rather than list
/// position (numbers are not renumbered when a chip is removed
/// mid-compose, so the two diverge).
pub const IMAGE_DISPLAY_NUMBER_META_KEY: &str = "xai.dev/imageDisplayNumber";

/// Build an ACP image-block `_meta` value carrying `display_number` under
/// [`IMAGE_DISPLAY_NUMBER_META_KEY`].
pub fn display_number_meta(display_number: usize) -> agent_client_protocol::Meta {
    let mut meta = agent_client_protocol::Meta::new();
    meta.insert(
        IMAGE_DISPLAY_NUMBER_META_KEY.to_owned(),
        serde_json::json!(display_number),
    );
    meta
}

/// Read the `[Image #N]` display number recorded in an image block's
/// `_meta`, if present.
pub fn display_number_from_meta(meta: Option<&agent_client_protocol::Meta>) -> Option<usize> {
    meta?
        .get(IMAGE_DISPLAY_NUMBER_META_KEY)?
        .as_u64()
        .and_then(|n| usize::try_from(n).ok())
}

/// File extensions accepted by the placeholder loader.
///
/// SVG is intentionally **not** in this list: SVG is XML text with no
/// reliable magic-byte signature, and adding it would expand the attack
/// surface (script tags, XXE) without a corresponding image-decoder
/// validation pass. Any future SVG support must be gated by a script
/// attack-surface review.
pub const ALLOWED_IMAGE_EXTENSIONS: &[&str] =
    &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif"];

/// Substrings that, if present anywhere in a canonical path, deny the
/// load even when the parent prefix is in the allowlist. Covers macOS
/// bundle subtrees the user did not explicitly opt in to sharing
/// (`~/Pictures/X.photoslibrary/originals/...`), Trash, and Keychain
/// bundles.
///
/// **Platform contract.** Each needle uses forward-slash separators
/// and is matched case-sensitively against the canonical path. The
/// enforcement site
/// ([`load_canonical_placeholder_image`]) normalises `\` → `/` before
/// the substring check, so Windows paths are covered. macOS HFS+
/// volumes (case-insensitive by default) and case-sensitive APFS both
/// hit the case-sensitive match — every entry in this list is a
/// system-emitted name and is case-stable in practice. If a future
/// entry depends on user-typed casing, add a `to_ascii_lowercase` step
/// at both sites.
pub const DENY_PATH_CONTAINS: &[&str] = &[
    ".photoslibrary/",
    ".musiclibrary/",
    ".imovielibrary/",
    "/.Trash/",
    "/Library/Keychains/",
    "/Library/Containers/",
    "/.ssh/",
    "/.aws/",
    "/.gnupg/",
];

/// Compiled regex matching the TUI placeholder format
/// `[Image #<digits>: <path>]`.
///
/// * Producer emits exactly `": "` (colon, single space) as the
///   separator — see [`kigi_tui::prompt_images::display_text`].
///   The regex requires the same; a path token like `[Image #5:foo]`
///   does **not** match.
/// * The path capture excludes `]`, `\n`, and `\r` so the match
///   terminates cleanly at the placeholder boundary even on
///   Windows-style line endings or path strings containing other
///   bracket forms.
/// * Path captures may contain spaces (typical macOS paths in
///   `~/My Pictures`).
static IMAGE_PLACEHOLDER_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\[Image #(\d+): ([^\]\r\n]+?)\]").expect("placeholder regex is valid")
});

/// One occurrence of `[Image #N: <path>]` in arbitrary text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlaceholderMatch {
    /// The `N` in `[Image #N: …]` (1-based per the TUI's convention).
    pub display_number: usize,
    /// The raw path string as it appeared in the text (no canonicalisation).
    pub path: String,
    /// Byte offsets into the source text covered by the full
    /// `[Image #N: …]` match — `text[start..end]` is the placeholder.
    pub span: (usize, usize),
}

/// Scan `text` for every well-formed placeholder.
///
/// Malformed forms (`[Image #3]`, `[Image #5: ]`, truncated, etc.) are
/// skipped without failing the whole scan. The regex iterator is
/// short-circuited via [`Iterator::take`] **before** `filter_map`, so a
/// pathological prompt with 100 000 placeholders does not consume
/// 100 000 captures — at most [`MAX_PLACEHOLDERS_PER_PROMPT`] are
/// inspected. Trade-off: a prompt with one invalid placeholder among
/// 16 valid ones may yield 15 results.
pub fn extract_placeholders(text: &str) -> Vec<PlaceholderMatch> {
    IMAGE_PLACEHOLDER_RE
        .captures_iter(text)
        .take(MAX_PLACEHOLDERS_PER_PROMPT)
        .filter_map(|cap| {
            let whole = cap.get(0)?;
            let n = cap.get(1)?.as_str().parse::<usize>().ok()?;
            let path = cap.get(2)?.as_str().trim();
            if path.is_empty() {
                return None;
            }
            Some(PlaceholderMatch {
                display_number: n,
                path: path.to_owned(),
                span: (whole.start(), whole.end()),
            })
        })
        .collect()
}

/// Rewrites every `[Image #N: <path>]` placeholder in `text` to the
/// shorter `[Image #N]` form, dropping the path component.
///
/// Run **after** the orphan-recovery pipeline has finished extracting
/// paths it needs to load. Once the image is attached inline the path
/// is redundant *and harmful*: the model treats it as a hint and may
/// call the `Read` tool on the path even though the bytes are already
/// in context. The bracketed anchor `[Image #N]` is preserved so the
/// model can still tell where in the prose the image was referenced.
///
/// Takes `String` by value so the common no-placeholder case returns
/// the input unchanged with zero allocations.
///
/// The scan is bounded by [`MAX_PLACEHOLDERS_PER_PROMPT`]; any extra
/// placeholders past the cap are left in their original form.
pub fn strip_paths_from_image_placeholders(text: String) -> String {
    use std::fmt::Write as _;
    // Fast path: probe with `is_match` (no `Captures` allocation) and
    // return the owned input unchanged when there is nothing to do.
    if !IMAGE_PLACEHOLDER_RE.is_match(&text) {
        return text;
    }
    let mut out = String::with_capacity(text.len());
    let mut last = 0usize;
    for cap in IMAGE_PLACEHOLDER_RE
        .captures_iter(&text)
        .take(MAX_PLACEHOLDERS_PER_PROMPT)
    {
        // Group 0 is the full match and group 1 is `(\d+)` — both are
        // structurally guaranteed by the regex.
        let whole = cap.get(0).expect("regex match always has group 0");
        let n = cap.get(1).expect("regex always has group 1").as_str();
        out.push_str(&text[last..whole.start()]);
        // `write!` to a String is infallible.
        let _ = write!(out, "[Image #{n}]");
        last = whole.end();
    }
    out.push_str(&text[last..]);
    out
}

/// Loaded image bytes plus a sniffed MIME type.
#[derive(Debug, Clone)]
pub struct LoadedPlaceholderImage {
    /// Raw image bytes read from disk. Ownership transferred to the
    /// caller so it can be base64-encoded or moved into a
    /// `ContentBlock::Image` without an intermediate clone.
    pub data: Vec<u8>,
    /// MIME type derived from the `image` crate's full header parser
    /// ([`image::ImageReader::with_guessed_format`] +
    /// `into_dimensions`). Always one of `image/png`, `image/jpeg`,
    /// `image/gif`, `image/webp`, `image/bmp`, `image/tiff`.
    pub mime_type: String,
}

/// Why a placeholder load was rejected.
///
/// The error variants are deliberately coarse-grained: an attacker with
/// log access should not be able to probe the filesystem by reading
/// distinct error messages. In particular, no variant carries the raw
/// `io::Error` string; an `io::ErrorKind` is retained where useful but
/// renderer-side messages stay generic.
#[derive(Debug, thiserror::Error)]
pub enum PlaceholderLoadError {
    /// Resolved path is outside every entry in the prefix allowlist
    /// (or matches a [`DENY_PATH_CONTAINS`] entry inside an allowed
    /// prefix).
    ///
    /// Returned **before** any further I/O so a path like `/etc/passwd`
    /// returns this variant rather than leaking that `/etc/passwd`
    /// exists.
    #[error("path is outside allowed prefixes")]
    OutsideAllowedPrefixes,
    /// Path could not be canonicalised (missing, permission denied,
    /// etc.). We never include the raw filesystem error to avoid log
    /// probing.
    #[error("path does not resolve")]
    CanonicalizeFailed,
    /// Extension is not in [`ALLOWED_IMAGE_EXTENSIONS`].
    #[error("unsupported image extension")]
    UnsupportedExtension,
    /// Resolved path is not a regular file (e.g. directory, FIFO).
    #[error("path is not a regular file")]
    NotAFile,
    /// `std::fs::read` failed after canonicalisation. The variant
    /// retains only the `io::ErrorKind`, not the verbose message.
    #[error("read failed: {0:?}")]
    ReadFailed(std::io::ErrorKind),
    /// File exceeds the configured per-image byte cap.
    #[error("file is {actual} bytes, exceeds {limit}-byte cap")]
    TooLarge { actual: usize, limit: usize },
    /// Bytes do not decode as a supported image. Routed through
    /// [`image::ImageReader::with_guessed_format`] +
    /// `into_dimensions`, so a file with PNG magic bytes followed by
    /// arbitrary content is rejected here.
    #[error("bytes do not decode as a supported image")]
    NotAnImage,
}

/// Build the canonical prefix allowlist with the workspace cwd plus a
/// small, intentional set of common user-image directories under
/// `$HOME`.
///
/// The list is canonicalised up-front so prefix checks against
/// canonical resolved paths work. Non-canonical paths are **never**
/// appended — if `dunce::canonicalize(workspace_cwd)` fails (transient
/// permission, missing dir), the workspace prefix is dropped entirely.
///
/// `$HOME` itself is **not** an allowed prefix: that would let
/// arbitrary placeholder paths under `~/.ssh`, `~/.aws`, `~/.config`,
/// etc. be exfil-able. Instead, only the typical user-paste image
/// directories are added.
pub fn default_allowed_prefixes(workspace_cwd: &Path) -> Vec<PathBuf> {
    default_allowed_prefixes_with_home(workspace_cwd, dirs::home_dir())
}

/// Test-injectable variant of [`default_allowed_prefixes`]. Production
/// code should call [`default_allowed_prefixes`]; tests pass an
/// explicit `home` so they don't depend on the ambient `$HOME`.
///
/// The returned `Vec` is canonical and deduplicated; **ordering is
/// alphabetical by OS path, not insertion order**. The prefix check in
/// [`load_placeholder_image`] uses `starts_with`, so order is
/// functionally irrelevant.
pub fn default_allowed_prefixes_with_home(
    workspace_cwd: &Path,
    home: Option<PathBuf>,
) -> Vec<PathBuf> {
    let mut prefixes: Vec<PathBuf> = Vec::new();
    match dunce::canonicalize(workspace_cwd) {
        Ok(canon) => prefixes.push(canon),
        Err(e) => {
            tracing::warn!(
                workspace_cwd = ?workspace_cwd,
                error_kind = ?e.kind(),
                "placeholder_images: workspace cwd does not canonicalize; dropping from allowlist",
            );
        }
    }
    if let Some(home) = home {
        for sub in HOME_IMAGE_SUBDIRS {
            if let Ok(canon) = dunce::canonicalize(home.join(sub)) {
                prefixes.push(canon);
            }
        }
    }
    prefixes.sort();
    prefixes.dedup();
    prefixes
}

/// Subdirectories under `$HOME` that are part of the default allowlist.
///
/// Chosen to match the directories users actually paste images from in
/// practice. Sensitive subtrees (`~/.ssh`, `~/.aws`, `~/.config`,
/// `~/.gnupg`, `~/Library/Keychains`) are intentionally excluded — they
/// are never added to the prefix list, and any path resolving into
/// [`DENY_PATH_CONTAINS`] is rejected even from inside an allowed
/// prefix.
pub const HOME_IMAGE_SUBDIRS: &[&str] = &[
    "Downloads",
    "Desktop",
    "Pictures",
    "Documents",
    "Screenshots",
];

/// Resolve and validate `path_str`, then read the file.
///
/// Validation order is **prefix-first** by design: an out-of-allowlist
/// path returns [`PlaceholderLoadError::OutsideAllowedPrefixes`]
/// regardless of whether the file exists, the extension is recognised,
/// or the bytes look like an image. This prevents log-level
/// information disclosure (an attacker with read access to telemetry
/// can no longer distinguish "file exists but is outside scope" from
/// "file does not exist").
///
/// `allowed_prefixes` should already be canonical (see
/// [`default_allowed_prefixes`]). The function does not canonicalise
/// `allowed_prefixes` again — the caller pays that cost once.
///
/// Symlinks: this loader follows symlinks (via `canonicalize`), then
/// checks the **resolved** path against the prefix allowlist. That is
/// strictly stronger than the legacy
/// [`kigi_tui::prompt_images::read_image_at_path`], which has no
/// prefix allowlist at all. The new rule applies to both
/// `[Image #N: <path>]` placeholder recovery callers — the
/// server-side `handle_prompt` fallback and the TUI orphan-placeholder
/// fallback. The legacy user-initiated drag/paste path in
/// `read_image_at_path` is intentionally outside this allowlist (the
/// user explicitly chose those files via the OS file picker).
pub fn load_placeholder_image(
    path_str: &str,
    allowed_prefixes: &[PathBuf],
) -> Result<LoadedPlaceholderImage, PlaceholderLoadError> {
    load_placeholder_image_with_cap(path_str, allowed_prefixes, MAX_PLACEHOLDER_IMAGE_BYTES)
}

/// Variant of [`load_placeholder_image`] that takes an explicit byte
/// cap. Used by tests so they can exercise the
/// [`PlaceholderLoadError::TooLarge`] path with a tiny cap and a small
/// file rather than synthesising a 50 MB blob.
pub fn load_placeholder_image_with_cap(
    path_str: &str,
    allowed_prefixes: &[PathBuf],
    max_bytes: usize,
) -> Result<LoadedPlaceholderImage, PlaceholderLoadError> {
    let canonical = dunce::canonicalize(Path::new(path_str))
        .map_err(|_| PlaceholderLoadError::CanonicalizeFailed)?;
    load_canonical_placeholder_image(&canonical, allowed_prefixes, max_bytes)
}

/// Variant of [`load_placeholder_image`] for callers that have already
/// canonicalised the path (e.g. [`recover_orphan_placeholders`], which
/// canonicalises once for dedup). Saves one `canonicalize` syscall per
/// successful load.
pub fn load_canonical_placeholder_image(
    canonical: &Path,
    allowed_prefixes: &[PathBuf],
    max_bytes: usize,
) -> Result<LoadedPlaceholderImage, PlaceholderLoadError> {
    // Prefix check first — out-of-scope paths return a single,
    // file-system-independent variant.
    if !allowed_prefixes.iter().any(|p| canonical.starts_with(p)) {
        return Err(PlaceholderLoadError::OutsideAllowedPrefixes);
    }
    // Deny-list pass: even inside an allowed prefix, certain subtrees
    // (macOS bundle internals, `.Trash`, secret stores) are off-limits.
    // Normalise `\` → `/` so Windows paths hit the same forward-slash
    // needles as Unix paths — see the `DENY_PATH_CONTAINS` doc-comment
    // for the platform contract.
    let canonical_str = canonical.to_string_lossy().replace('\\', "/");
    if DENY_PATH_CONTAINS
        .iter()
        .any(|needle| canonical_str.contains(needle))
    {
        return Err(PlaceholderLoadError::OutsideAllowedPrefixes);
    }

    let ext = canonical
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| e.to_ascii_lowercase())
        .ok_or(PlaceholderLoadError::UnsupportedExtension)?;
    if !ALLOWED_IMAGE_EXTENSIONS.contains(&ext.as_str()) {
        return Err(PlaceholderLoadError::UnsupportedExtension);
    }

    let metadata = canonical
        .metadata()
        .map_err(|e| PlaceholderLoadError::ReadFailed(e.kind()))?;
    if !metadata.is_file() {
        return Err(PlaceholderLoadError::NotAFile);
    }
    let size = metadata.len() as usize;
    if size > max_bytes {
        return Err(PlaceholderLoadError::TooLarge {
            actual: size,
            limit: max_bytes,
        });
    }

    let data = std::fs::read(canonical).map_err(|e| PlaceholderLoadError::ReadFailed(e.kind()))?;
    // Re-check after read: a sparse/grown file may exceed the cap
    // even when the metadata snapshot was under it.
    if data.len() > max_bytes {
        return Err(PlaceholderLoadError::TooLarge {
            actual: data.len(),
            limit: max_bytes,
        });
    }

    // Image-decoder validation: routed through the `image` crate's
    // header parser so a file with PNG magic bytes followed by arbitrary
    // content (e.g. a private key) is rejected. `into_dimensions` reads
    // the header (cheap) but not the pixel payload (expensive), so a
    // truncated/garbled image fails fast.
    let mime_type = decode_image_mime(&data).ok_or(PlaceholderLoadError::NotAnImage)?;

    Ok(LoadedPlaceholderImage {
        data,
        mime_type: mime_type.to_owned(),
    })
}

/// Header-only validation via the shared image_validate helper.
/// Returns the matching MIME type, or `None` if the bytes fail validation.
fn decode_image_mime(data: &[u8]) -> Option<&'static str> {
    kigi_tools::util::image_validate::validate_image_bytes_with(data, false)
        .ok()
        .map(|(_, _, mime)| mime)
}

/// Recover orphan `[Image #N: <path>]` placeholders embedded in the
/// user query text by loading the referenced files from disk.
///
/// Production wrapper over [`recover_orphan_placeholders_with_prefixes`]
/// that derives the prefix allowlist from `workspace_cwd` via
/// [`default_allowed_prefixes`].
///
/// **Note for integration tests.** This wrapper reads the ambient
/// process `$HOME` via `dirs::home_dir()` to construct
/// [`HOME_IMAGE_SUBDIRS`] prefixes. An end-to-end test driving
/// `handle_prompt` therefore inherits the test runner's `$HOME` and
/// any subdirectories it materialises (`~/Downloads`, etc.) into the
/// allowlist. For hermetic test isolation, call
/// [`recover_orphan_placeholders_with_prefixes`] directly with an
/// explicit prefix list — see the unit tests of this module for the
/// pattern.
pub fn recover_orphan_placeholders(
    query: &str,
    raw_images: &mut Vec<agent_client_protocol::ImageContent>,
    workspace_cwd: &Path,
) -> usize {
    let allowed = default_allowed_prefixes(workspace_cwd);
    recover_orphan_placeholders_with_prefixes(query, raw_images, &allowed)
}

/// Inject-friendly variant of [`recover_orphan_placeholders`].
///
/// "Orphan" = a placeholder whose canonical path is **not** already
/// present in `raw_images` (i.e. the TUI did not send a matching
/// `ContentBlock::Image`).
///
/// Dedup is performed against the **canonical** form of each existing
/// `raw_images[i].uri` so a TUI-attached non-canonical `file://`
/// URI (e.g. `file:///tmp/foo.png` when the canonical path is
/// `/private/tmp/foo.png`) still matches the placeholder's canonical
/// form. Percent-encoded forms are also handled (see
/// [`canonical_from_file_uri`]).
///
/// Enforces [`MAX_PLACEHOLDER_AGGREGATE_BYTES`] across the recovered
/// payloads: once the running total would exceed the cap, the
/// remainder of the placeholders are skipped with a `warn` log.
///
/// Returns the number of recovered images so the caller can log a
/// summary.
pub fn recover_orphan_placeholders_with_prefixes(
    query: &str,
    raw_images: &mut Vec<agent_client_protocol::ImageContent>,
    allowed_prefixes: &[PathBuf],
) -> usize {
    recover_orphan_placeholders_with_prefixes_and_caps(
        query,
        raw_images,
        allowed_prefixes,
        MAX_PLACEHOLDER_IMAGE_BYTES,
        MAX_PLACEHOLDER_AGGREGATE_BYTES,
    )
}

/// Test-injectable variant of
/// [`recover_orphan_placeholders_with_prefixes`].
///
/// Production code calls the cap-defaulting wrapper; tests use this
/// form to exercise the aggregate cap with small synthetic values
/// (real 200 MB tests would burn disk/CPU per run).
///
/// Aggregate-cap semantics: the loop reads the next image, then checks
/// `aggregate_bytes + image.len() > aggregate_max`. The first image
/// that pushes the running total **strictly above** the cap is
/// dropped and the loop `break`s; earlier images already in
/// `raw_images` stay. The cap is therefore an **inclusive** upper
/// bound on the aggregate — a running total exactly equal to the cap
/// is admitted, the next byte trips the break. See test
/// `recover_orphan_placeholders_aggregate_cap_inclusive_boundary`.
pub fn recover_orphan_placeholders_with_prefixes_and_caps(
    query: &str,
    raw_images: &mut Vec<agent_client_protocol::ImageContent>,
    allowed_prefixes: &[PathBuf],
    per_image_max: usize,
    aggregate_max: usize,
) -> usize {
    use base64::Engine as _;

    let placeholders = extract_placeholders(query);
    if placeholders.is_empty() {
        return 0;
    }

    // Pre-compute canonical paths of the already-attached images so the
    // dedup is robust against non-canonical `file://` URIs from the
    // TUI side as well as percent-encoded URIs from non-TUI clients.
    let attached_canonical: std::collections::HashSet<PathBuf> = raw_images
        .iter()
        .filter_map(|img| img.uri.as_deref().and_then(canonical_from_file_uri))
        .collect();

    let mut recovered: usize = 0;
    let mut aggregate_bytes: usize = 0;
    for ph in placeholders {
        let canonical = match dunce::canonicalize(Path::new(&ph.path)) {
            Ok(c) => c,
            Err(_) => {
                tracing::warn!(
                    path = ?ph.path,
                    "placeholder_images: orphan path does not resolve; leaving placeholder text intact",
                );
                continue;
            }
        };
        if attached_canonical.contains(&canonical) {
            continue;
        }
        match load_canonical_placeholder_image(&canonical, allowed_prefixes, per_image_max) {
            Ok(loaded) => {
                let next_total = aggregate_bytes.saturating_add(loaded.data.len());
                if next_total > aggregate_max {
                    tracing::warn!(
                        path = ?ph.path,
                        aggregate_bytes,
                        per_image_bytes = loaded.data.len(),
                        cap = aggregate_max,
                        "placeholder_images: aggregate-bytes cap reached; skipping remaining orphan placeholders",
                    );
                    break;
                }
                aggregate_bytes = next_total;
                let data = base64::engine::general_purpose::STANDARD.encode(&loaded.data);
                raw_images.push(
                    agent_client_protocol::ImageContent::new(data, loaded.mime_type)
                        .uri(format!("file://{}", canonical.display()))
                        // Record the real `[Image #N]` number so it resolves by
                        // number, matching the TUI-attached images (which set it
                        // too) and avoiding position-based collisions.
                        .meta(display_number_meta(ph.display_number)),
                );
                recovered += 1;
            }
            Err(e) => {
                tracing::warn!(
                    path = ?ph.path,
                    error = %e,
                    "placeholder_images: orphan placeholder failed to load",
                );
            }
        }
    }
    recovered
}

/// Parse a `file://...` URI into a canonical `PathBuf`, accepting both
/// the relaxed unencoded form emitted by the TUI / server (see the
/// `file://` URI convention note in the module header) and the
/// percent-encoded RFC 3986 form. Returns `None` if the URI does not
/// start with `file://`; otherwise returns the canonicalised path
/// (falling back to the raw path when canonicalisation fails so the
/// caller can still compare against attached URIs).
pub fn canonical_from_file_uri(uri: &str) -> Option<PathBuf> {
    let raw_path_str = uri.strip_prefix("file://")?;
    // Try percent-decoding first; fall back to the literal form. Both
    // are valid per the module's `file://` URI convention.
    let decoded: std::borrow::Cow<'_, str> = match urlencoding::decode(raw_path_str) {
        Ok(c) => c,
        Err(_) => std::borrow::Cow::Borrowed(raw_path_str),
    };
    let raw = Path::new(decoded.as_ref());
    Some(dunce::canonicalize(raw).unwrap_or_else(|_| raw.to_path_buf()))
}

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

    /// Minimal valid 1x1 PNG (real format, decodable by `image`).
    const PNG_BYTES: &[u8] = &[
        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, 0x06, 0x00, 0x00, 0x00, 0x1F,
        0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00,
        0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
        0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
    ];

    fn write_png(dir: &Path, name: &str) -> PathBuf {
        let path = dir.join(name);
        let mut f = std::fs::File::create(&path).unwrap();
        f.write_all(PNG_BYTES).unwrap();
        path
    }

    // ----- strip_paths_from_image_placeholders ---------------------------

    #[test]
    fn strip_paths_drops_path_keeps_anchor() {
        // The whole point: the model should see the bracketed anchor
        // `[Image #N]` but not the path that would tempt a `Read`.
        assert_eq!(
            strip_paths_from_image_placeholders(
                "what is that?[Image #1: /Users/me/Desktop/x.png] thanks".to_owned()
            ),
            "what is that?[Image #1] thanks"
        );
    }

    #[test]
    fn strip_paths_handles_multiple_placeholders_and_spaces_in_paths() {
        assert_eq!(
            strip_paths_from_image_placeholders(
                "[Image #1: /tmp/a.png] mid [Image #2: /home/u/My Pictures/b.jpg] tail".to_owned()
            ),
            "[Image #1] mid [Image #2] tail"
        );
    }

    #[test]
    fn strip_paths_returns_input_unchanged_when_no_placeholders() {
        // Fast-path: the helper takes `String` by value and the
        // no-match branch returns it verbatim — no allocation. The
        // identity here pins the contract (input string ⇔ output
        // string) byte-for-byte.
        let text = "no placeholder here, just prose";
        assert_eq!(strip_paths_from_image_placeholders(text.to_owned()), text);
    }

    #[test]
    fn strip_paths_preserves_surrounding_whitespace_and_unicode() {
        assert_eq!(
            strip_paths_from_image_placeholders(
                "café \u{202f}[Image #4: /Users/me/Desktop/Screenshot 2026-05-22 at 16.01.21.png] ok"
                    .to_owned()
            ),
            "café \u{202f}[Image #4] ok"
        );
    }

    #[test]
    fn strip_paths_ignores_malformed_placeholders() {
        // None of these match the regex (the last is unterminated, the
        // middle two have empty paths). The leading `[Image #1]` is
        // already in the short form, so the output is bit-identical
        // to the input.
        let text = "[Image #1] [Image #2:] [Image #3: ] [Image #4: /ok.png";
        assert_eq!(strip_paths_from_image_placeholders(text.to_owned()), text);
    }

    // ----- extract_placeholders ------------------------------------------

    #[test]
    fn extract_placeholders_basic() {
        let text = "look at [Image #1: /tmp/a.png] and [Image #2: /home/user/b.jpg]";
        let matches = extract_placeholders(text);
        assert_eq!(matches.len(), 2);
        assert_eq!(matches[0].display_number, 1);
        assert_eq!(matches[0].path, "/tmp/a.png");
        assert_eq!(matches[1].display_number, 2);
        assert_eq!(matches[1].path, "/home/user/b.jpg");
    }

    #[test]
    fn extract_placeholders_with_spaces_in_path() {
        let text = "[Image #3: /Users/me/My Pictures/cat.png]";
        let matches = extract_placeholders(text);
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].path, "/Users/me/My Pictures/cat.png");
    }

    #[test]
    fn extract_placeholders_rejects_partial_and_invalid_forms() {
        assert!(extract_placeholders("[Image #3] only").is_empty());
        assert!(extract_placeholders("[Image #4: ]").is_empty());
        assert!(extract_placeholders("[Image #5: /tmp/x.png").is_empty());
        assert!(extract_placeholders("[image #1: /tmp/x.png]").is_empty());
        assert!(extract_placeholders("[Image #: /tmp/x.png]").is_empty());
        // Missing space after colon: producer always emits ": " so the
        // shorthand form is intentionally rejected. Pinned here.
        assert!(extract_placeholders("[Image #5:foo.png]").is_empty());
    }

    #[test]
    fn extract_placeholders_inside_larger_text_returns_spans() {
        let text = "before [Image #7: /tmp/a.png] after";
        let matches = extract_placeholders(text);
        assert_eq!(matches.len(), 1);
        let (start, end) = matches[0].span;
        assert_eq!(&text[start..end], "[Image #7: /tmp/a.png]");
    }

    #[test]
    fn extract_placeholders_unicode_path() {
        let text = "[Image #9: /tmp/café.png]";
        let matches = extract_placeholders(text);
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].path, "/tmp/café.png");
    }

    #[test]
    fn extract_placeholders_large_display_number() {
        let text = "[Image #99999: /tmp/a.png]";
        let matches = extract_placeholders(text);
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].display_number, 99999);
    }

    #[test]
    fn extract_placeholders_display_number_zero_is_kept() {
        let text = "[Image #0: /tmp/a.png]";
        let matches = extract_placeholders(text);
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].display_number, 0);
    }

    #[test]
    fn extract_placeholders_multiple_on_same_line() {
        let text = "[Image #1: /tmp/a.png] [Image #2: /tmp/b.png] [Image #3: /tmp/c.png]";
        let matches = extract_placeholders(text);
        assert_eq!(matches.len(), 3);
        assert_eq!(matches[0].display_number, 1);
        assert_eq!(matches[1].display_number, 2);
        assert_eq!(matches[2].display_number, 3);
    }

    #[test]
    fn extract_placeholders_at_text_boundaries() {
        let matches = extract_placeholders("[Image #1: /tmp/a.png] tail");
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].span.0, 0);
        let text2 = "head [Image #2: /tmp/b.png]";
        let matches2 = extract_placeholders(text2);
        assert_eq!(matches2.len(), 1);
        assert_eq!(matches2[0].span.1, text2.len());
    }

    #[test]
    fn extract_placeholders_rejects_carriage_return_in_path() {
        let text = "[Image #1: /tmp/a\r.png]";
        assert!(extract_placeholders(text).is_empty());
    }

    #[test]
    fn extract_placeholders_caps_at_max_per_prompt() {
        let chunk = "[Image #1: /tmp/a.png] ".repeat(MAX_PLACEHOLDERS_PER_PROMPT + 5);
        let matches = extract_placeholders(&chunk);
        assert_eq!(matches.len(), MAX_PLACEHOLDERS_PER_PROMPT);
    }

    #[test]
    fn extract_placeholders_first_nested_bracket_terminates() {
        // Pinning behaviour: `]` inside the path closes the match
        // early. Result: the first segment is captured as the path,
        // the rest of the text is left alone. Also pin the span so a
        // future regex revision that consumes nested brackets is
        // caught.
        let text = "[Image #1: /tmp/[odd].png]";
        let matches = extract_placeholders(text);
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].path, "/tmp/[odd");
        let (start, end) = matches[0].span;
        assert_eq!(&text[start..end], "[Image #1: /tmp/[odd]");
    }

    // ----- load_placeholder_image ----------------------------------------

    #[test]
    fn load_placeholder_image_happy_path() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_png(dir.path(), "ok.png");
        let canon = dunce::canonicalize(dir.path()).unwrap();
        let loaded =
            load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon)).unwrap();
        assert_eq!(loaded.mime_type, "image/png");
        assert_eq!(loaded.data, PNG_BYTES);
    }

    #[test]
    fn load_placeholder_image_rejects_missing_path() {
        let dir = tempfile::tempdir().unwrap();
        let canon = dunce::canonicalize(dir.path()).unwrap();
        let missing = dir.path().join("does-not-exist.png");
        let err = load_placeholder_image(missing.to_str().unwrap(), std::slice::from_ref(&canon))
            .unwrap_err();
        assert!(matches!(err, PlaceholderLoadError::CanonicalizeFailed));
    }

    #[test]
    fn load_placeholder_image_rejects_unsupported_extension() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("notes.txt");
        std::fs::write(&path, b"hello").unwrap();
        let canon = dunce::canonicalize(dir.path()).unwrap();
        let err = load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon))
            .unwrap_err();
        assert!(matches!(err, PlaceholderLoadError::UnsupportedExtension));
    }

    #[test]
    fn load_placeholder_image_rejects_outside_allowed_prefixes() {
        let real_dir = tempfile::tempdir().unwrap();
        let png = write_png(real_dir.path(), "real.png");
        let other_dir = tempfile::tempdir().unwrap();
        let other_canon = dunce::canonicalize(other_dir.path()).unwrap();
        let err = load_placeholder_image(png.to_str().unwrap(), std::slice::from_ref(&other_canon))
            .unwrap_err();
        assert!(matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes));
    }

    #[test]
    fn load_placeholder_image_rejects_not_an_image_with_image_extension() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("fake.png");
        std::fs::write(&path, b"not actually a png").unwrap();
        let canon = dunce::canonicalize(dir.path()).unwrap();
        let err = load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon))
            .unwrap_err();
        assert!(matches!(err, PlaceholderLoadError::NotAnImage));
    }

    /// Defence-in-depth regression for the exfil chain: a file whose
    /// first 8 bytes are PNG magic but whose tail is arbitrary content
    /// (e.g. a private key) is **rejected** by the loader. Earlier
    /// rounds pinned the weaker `image::guess_format`-only behaviour
    /// which accepted such forgeries; the full
    /// `with_guessed_format` + `into_dimensions` check closes that
    /// gap.
    #[test]
    fn load_placeholder_image_rejects_png_magic_forgery_with_garbage_tail() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("forged.png");
        let mut bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
        bytes.extend(b"PRIVATE KEY DATA - not a real PNG");
        std::fs::write(&path, &bytes).unwrap();
        let canon = dunce::canonicalize(dir.path()).unwrap();
        let err = load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon))
            .unwrap_err();
        assert!(
            matches!(err, PlaceholderLoadError::NotAnImage),
            "expected NotAnImage, got: {err:?}"
        );
    }

    #[test]
    fn load_placeholder_image_rejects_directory_as_not_a_file() {
        let allowed = tempfile::tempdir().unwrap();
        let dir_path = allowed.path().join("looks-like.png");
        std::fs::create_dir(&dir_path).unwrap();
        let canon = dunce::canonicalize(allowed.path()).unwrap();
        let err = load_placeholder_image(dir_path.to_str().unwrap(), std::slice::from_ref(&canon))
            .unwrap_err();
        assert!(matches!(err, PlaceholderLoadError::NotAFile));
    }

    #[cfg(unix)]
    #[test]
    fn load_placeholder_image_reports_read_failure_on_unreadable_file() {
        // SAFETY: getuid is always safe; we just don't want to import libc.
        let euid = unsafe { libc::geteuid() };
        if euid == 0 {
            return;
        }
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("locked.png");
        std::fs::write(&path, PNG_BYTES).unwrap();
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
        let canon = dunce::canonicalize(dir.path()).unwrap();
        let err = load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon))
            .unwrap_err();
        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
        assert!(
            matches!(err, PlaceholderLoadError::ReadFailed(_)),
            "expected ReadFailed, got: {err:?}"
        );
    }

    #[test]
    fn load_placeholder_image_rejects_oversize_via_injectable_cap() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_png(dir.path(), "small-but-over-cap.png");
        let canon = dunce::canonicalize(dir.path()).unwrap();
        let err = load_placeholder_image_with_cap(
            path.to_str().unwrap(),
            std::slice::from_ref(&canon),
            32,
        )
        .unwrap_err();
        match err {
            PlaceholderLoadError::TooLarge { actual, limit } => {
                assert_eq!(actual, PNG_BYTES.len());
                assert_eq!(limit, 32);
            }
            other => panic!("expected TooLarge, got {other:?}"),
        }
    }

    #[cfg(unix)]
    #[test]
    fn load_placeholder_image_rejects_symlink_escape() {
        let allowed_dir = tempfile::tempdir().unwrap();
        let outside_dir = tempfile::tempdir().unwrap();
        let target = write_png(outside_dir.path(), "secret.png");
        let link = allowed_dir.path().join("link.png");
        std::os::unix::fs::symlink(&target, &link).unwrap();

        let allowed_canon = dunce::canonicalize(allowed_dir.path()).unwrap();
        let err =
            load_placeholder_image(link.to_str().unwrap(), std::slice::from_ref(&allowed_canon))
                .unwrap_err();
        assert!(
            matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes),
            "symlink escape must canonicalize then trip the prefix check, got: {err:?}",
        );
    }

    #[test]
    fn load_placeholder_image_rejects_path_traversal_via_dotdot() {
        let allowed_dir = tempfile::tempdir().unwrap();
        let outside_dir = tempfile::tempdir().unwrap();
        let unique = format!("ph-traversal-{}.png", uuid::Uuid::new_v4());
        let outside = outside_dir.path().join(&unique);
        std::fs::write(&outside, PNG_BYTES).unwrap();

        let traversal = allowed_dir
            .path()
            .join("..")
            .join(outside_dir.path().file_name().unwrap())
            .join(&unique);
        let allowed_canon = dunce::canonicalize(allowed_dir.path()).unwrap();
        let err = load_placeholder_image(
            traversal.to_str().unwrap(),
            std::slice::from_ref(&allowed_canon),
        )
        .unwrap_err();
        assert!(matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes));
    }

    #[test]
    fn load_placeholder_image_rejects_deny_listed_subtree() {
        // Construct a workspace whose canonical contains
        // `/.photoslibrary/` so the path is inside the allowlist but
        // hits the deny-list.
        let root = tempfile::tempdir().unwrap();
        let bundle = root
            .path()
            .join("Photos Library.photoslibrary")
            .join("originals");
        std::fs::create_dir_all(&bundle).unwrap();
        let png = write_png(&bundle, "hash.png");
        let allowed_canon = dunce::canonicalize(root.path()).unwrap();
        let err =
            load_placeholder_image(png.to_str().unwrap(), std::slice::from_ref(&allowed_canon))
                .unwrap_err();
        assert!(
            matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes),
            "deny-listed subtree must be rejected even inside allowlist, got: {err:?}"
        );
    }

    // ----- default_allowed_prefixes / _with_home --------------------------

    #[test]
    fn default_allowed_prefixes_with_home_includes_workspace_and_every_subdir() {
        let dir = tempfile::tempdir().unwrap();
        let home = tempfile::tempdir().unwrap();
        // Materialise every entry in HOME_IMAGE_SUBDIRS so the list
        // drift is caught at test time, not at runtime.
        let mut expected_subdir_canons: Vec<PathBuf> = Vec::new();
        for sub in HOME_IMAGE_SUBDIRS {
            let p = home.path().join(sub);
            std::fs::create_dir(&p).unwrap();
            expected_subdir_canons.push(dunce::canonicalize(&p).unwrap());
        }

        let prefixes =
            default_allowed_prefixes_with_home(dir.path(), Some(home.path().to_path_buf()));
        let dir_canon = dunce::canonicalize(dir.path()).unwrap();
        assert!(prefixes.contains(&dir_canon));
        for canon in &expected_subdir_canons {
            assert!(
                prefixes.contains(canon),
                "missing prefix for {canon:?} in {prefixes:?}"
            );
        }
        // $HOME itself is NOT in the list.
        assert!(!prefixes.contains(&dunce::canonicalize(home.path()).unwrap()));
        // At least workspace + each subdir, sorted+deduped. Uses `>=`
        // not `==` so the test stays green if `$TMPDIR` happens to
        // resolve inside one of the home subdirs (e.g. CI runners that
        // set `TMPDIR=$HOME/Downloads/ci`) — in that case the
        // workspace canonical would equal one of the subdir canonicals
        // and the dedup pass collapses them.
        assert!(
            prefixes.len() >= HOME_IMAGE_SUBDIRS.len(),
            "expected at least {} prefixes, got {prefixes:?}",
            HOME_IMAGE_SUBDIRS.len()
        );
    }

    #[test]
    fn default_allowed_prefixes_with_home_unset_returns_workspace_only() {
        let dir = tempfile::tempdir().unwrap();
        let prefixes = default_allowed_prefixes_with_home(dir.path(), None);
        let dir_canon = dunce::canonicalize(dir.path()).unwrap();
        assert_eq!(prefixes, vec![dir_canon]);
    }

    #[test]
    fn default_allowed_prefixes_dedups_workspace_equal_home_subdir() {
        let home = tempfile::tempdir().unwrap();
        let downloads = home.path().join("Downloads");
        std::fs::create_dir(&downloads).unwrap();
        let prefixes =
            default_allowed_prefixes_with_home(&downloads, Some(home.path().to_path_buf()));
        let dl_canon = dunce::canonicalize(&downloads).unwrap();
        let count = prefixes.iter().filter(|p| **p == dl_canon).count();
        assert_eq!(
            count, 1,
            "duplicate canonical prefix not deduped: {prefixes:?}"
        );
    }

    #[test]
    fn default_allowed_prefixes_drops_non_canonical_workspace() {
        let prefixes = default_allowed_prefixes_with_home(
            Path::new("/nonexistent/abs/path/we/never/created"),
            None,
        );
        assert!(
            prefixes.is_empty(),
            "non-canonical workspace must not be added to the allowlist: {prefixes:?}"
        );
    }

    // ----- canonical_from_file_uri ----------------------------------------

    #[test]
    fn canonical_from_file_uri_rejects_non_file_scheme() {
        assert!(canonical_from_file_uri("https://example.com/x.png").is_none());
        assert!(canonical_from_file_uri("/raw/path").is_none());
    }

    #[test]
    fn canonical_from_file_uri_handles_percent_encoded_path() {
        let dir = tempfile::tempdir().unwrap();
        let with_space = dir.path().join("My Pictures");
        std::fs::create_dir(&with_space).unwrap();
        let png = write_png(&with_space, "cat.png");
        let canon = dunce::canonicalize(&png).unwrap();
        // RFC 3986 form: spaces percent-encoded.
        let raw = format!("file://{}", png.display());
        let encoded = raw.replace(' ', "%20");
        let parsed = canonical_from_file_uri(&encoded).unwrap();
        assert_eq!(parsed, canon);
    }

    // ----- recover_orphan_placeholders (hermetic, no ambient $HOME) -------

    /// Build a non-empty ACP `ImageContent` so a future dedup change
    /// that short-circuits on `data.is_empty()` cannot silently pass
    /// these tests.
    fn make_acp_image(uri: &str) -> agent_client_protocol::ImageContent {
        agent_client_protocol::ImageContent::new("AAAA", "image/png").uri(Some(uri.to_string()))
    }

    #[test]
    fn recover_orphan_placeholders_loads_orphan() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_png(dir.path(), "rec.png");
        let canon = dunce::canonicalize(&path).unwrap();
        let query = format!("look at [Image #1: {}]", canon.display());
        let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
        let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
        let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
        assert_eq!(n, 1);
        assert_eq!(raw.len(), 1);
        assert_eq!(raw[0].mime_type, "image/png");
        assert!(!raw[0].data.is_empty());
        assert!(raw[0].uri.as_deref().unwrap().starts_with("file://"));
        // The recovered image carries its real `[Image #N]` number so
        // `image_edit` can resolve the token to it by number.
        assert_eq!(display_number_from_meta(raw[0].meta.as_ref()), Some(1));
    }

    #[test]
    fn recover_orphan_placeholders_dedupes_against_canonical_uri() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_png(dir.path(), "dup.png");
        let canon = dunce::canonicalize(&path).unwrap();
        let attached_uri = format!("file://{}", canon.display());
        let mut raw = vec![make_acp_image(&attached_uri)];
        let query = format!("see [Image #1: {}]", canon.display());
        let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
        let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
        assert_eq!(n, 0, "canonical-canonical dedup must skip the load");
        assert_eq!(raw.len(), 1);
        // The original entry must be untouched, not silently
        // overwritten by a duplicate load.
        assert_eq!(raw[0].data, "AAAA");
    }

    #[cfg(unix)]
    #[test]
    fn recover_orphan_placeholders_dedupes_against_non_canonical_tui_uri() {
        let dir = tempfile::tempdir().unwrap();
        let outside_root = tempfile::tempdir().unwrap();
        let real_target = write_png(outside_root.path(), "data.png");
        let link = dir.path().join("link.png");
        std::os::unix::fs::symlink(&real_target, &link).unwrap();

        let attached_uri = format!("file://{}", link.display()); // non-canonical
        let mut raw = vec![make_acp_image(&attached_uri)];
        let canonical_placeholder = dunce::canonicalize(&real_target).unwrap();
        let query = format!("[Image #1: {}]", canonical_placeholder.display());
        let allowed = vec![dunce::canonicalize(outside_root.path()).unwrap()];
        let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
        assert_eq!(
            n, 0,
            "non-canonical TUI URI must still dedup against canonical placeholder path"
        );
        assert_eq!(raw.len(), 1);
        assert_eq!(raw[0].data, "AAAA");
    }

    #[test]
    fn recover_orphan_placeholders_dedupes_against_percent_encoded_uri() {
        let dir = tempfile::tempdir().unwrap();
        let space_dir = dir.path().join("My Pictures");
        std::fs::create_dir(&space_dir).unwrap();
        let png = write_png(&space_dir, "cat.png");
        let canon = dunce::canonicalize(&png).unwrap();
        // Attached image URI uses RFC 3986 percent-encoded form.
        let raw_form = format!("file://{}", canon.display());
        let encoded = raw_form.replace(' ', "%20");
        let mut raw = vec![make_acp_image(&encoded)];
        let query = format!("[Image #1: {}]", canon.display());
        let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
        let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
        assert_eq!(
            n, 0,
            "percent-encoded `file://` URI must dedup against canonical placeholder"
        );
        assert_eq!(raw.len(), 1);
        assert_eq!(raw[0].data, "AAAA");
    }

    /// Pin the inverse direction. The placeholder text wire
    /// format is the unencoded path produced by
    /// `kigi_tui::prompt_images::display_text`. A
    /// percent-encoded path *inside the placeholder text* is **not**
    /// supported — `extract_placeholders` captures the raw `%20`
    /// substring and `canonicalize` rejects the synthetic name. The
    /// orphan loader logs a warn and skips the placeholder. The
    /// already-attached canonical URI in `raw_images` stays
    /// untouched.
    #[test]
    fn recover_orphan_placeholders_percent_encoded_path_in_placeholder_is_not_supported() {
        let dir = tempfile::tempdir().unwrap();
        let space_dir = dir.path().join("My Pictures");
        std::fs::create_dir(&space_dir).unwrap();
        let png = write_png(&space_dir, "cat.png");
        let canon = dunce::canonicalize(&png).unwrap();
        let attached_uri = format!("file://{}", canon.display());
        let mut raw = vec![make_acp_image(&attached_uri)];
        // Percent-encoded path inside the placeholder text — not the
        // documented wire format.
        let encoded_in_text = format!("{}", canon.display()).replace(' ', "%20");
        let query = format!("[Image #1: {}]", encoded_in_text);
        let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
        let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
        assert_eq!(n, 0);
        assert_eq!(raw.len(), 1, "attached URI must remain intact");
        assert_eq!(raw[0].data, "AAAA");
    }

    #[test]
    fn recover_orphan_placeholders_zero_placeholders_short_circuits() {
        let dir = tempfile::tempdir().unwrap();
        let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
        let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
        let n = recover_orphan_placeholders_with_prefixes("just a message", &mut raw, &allowed);
        assert_eq!(n, 0);
        assert!(raw.is_empty());
    }

    #[test]
    fn recover_orphan_placeholders_failed_load_leaves_raw_images_untouched() {
        let dir = tempfile::tempdir().unwrap();
        let missing = dir.path().join("nope.png");
        let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
        let query = format!("[Image #1: {}]", missing.display());
        let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
        let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
        assert_eq!(n, 0);
        assert!(raw.is_empty());
    }

    #[test]
    fn recover_orphan_placeholders_outside_allowlist_rejected() {
        let workspace = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        let png = write_png(outside.path(), "secret.png");
        let canon = dunce::canonicalize(&png).unwrap();
        let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
        let query = format!("[Image #1: {}]", canon.display());
        // Allowlist is workspace only — the placeholder canon is
        // outside it.
        let allowed = vec![dunce::canonicalize(workspace.path()).unwrap()];
        let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
        assert_eq!(n, 0);
        assert!(raw.is_empty());
    }

    // ----- Aggregate cap -------------------------------------------------

    /// Two placeholders, aggregate cap below the cumulative byte
    /// total of both. The first image fits; the second pushes the
    /// running total over and the loop breaks. Pin: `n == 1`, second
    /// placeholder was NOT loaded into `raw`.
    #[test]
    fn recover_orphan_placeholders_aggregate_cap_breaks_loop() {
        let dir = tempfile::tempdir().unwrap();
        let p1 = write_png(dir.path(), "a.png");
        let p2 = write_png(dir.path(), "b.png");
        let c1 = dunce::canonicalize(&p1).unwrap();
        let c2 = dunce::canonicalize(&p2).unwrap();
        let query = format!("[Image #1: {}] [Image #2: {}]", c1.display(), c2.display());
        let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
        let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
        // Per-image cap permissive; aggregate cap admits exactly one
        // image (PNG_BYTES is 67 bytes; cap at 100 lets one through,
        // blocks the second).
        let n = recover_orphan_placeholders_with_prefixes_and_caps(
            &query, &mut raw, &allowed, 1_000, 100,
        );
        assert_eq!(n, 1, "aggregate cap must allow exactly one image");
        assert_eq!(raw.len(), 1);
        // Order matters — the first placeholder's canonical URI is
        // the one that landed in `raw`.
        let attached_uri = raw[0].uri.as_deref().unwrap();
        assert!(
            attached_uri.contains("a.png"),
            "expected the first placeholder to be the one kept, got: {attached_uri}"
        );
    }

    /// Pin the aggregate-cap boundary semantics. The cap is
    /// **inclusive** — `aggregate + image.len() > cap` triggers the
    /// break. A single image exactly equal to the cap is admitted.
    #[test]
    fn recover_orphan_placeholders_aggregate_cap_inclusive_boundary() {
        let dir = tempfile::tempdir().unwrap();
        let p = write_png(dir.path(), "one.png");
        let c = dunce::canonicalize(&p).unwrap();
        let query = format!("[Image #1: {}]", c.display());
        let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
        let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
        // Cap == image size: the `>` comparison admits this image.
        let n = recover_orphan_placeholders_with_prefixes_and_caps(
            &query,
            &mut raw,
            &allowed,
            1_000,
            PNG_BYTES.len(),
        );
        assert_eq!(n, 1);
        assert_eq!(raw.len(), 1);
    }

    /// The reject side of the inclusive-boundary contract.
    /// `cap == image_size - 1` is the smallest cap that rejects this
    /// image. Two-sided boundary pinning locks down the inclusive vs
    /// exclusive contract.
    #[test]
    fn recover_orphan_placeholders_aggregate_cap_inclusive_boundary_rejects_at_one_below() {
        let dir = tempfile::tempdir().unwrap();
        let p = write_png(dir.path(), "one.png");
        let c = dunce::canonicalize(&p).unwrap();
        let query = format!("[Image #1: {}]", c.display());
        let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
        let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
        let n = recover_orphan_placeholders_with_prefixes_and_caps(
            &query,
            &mut raw,
            &allowed,
            1_000,
            PNG_BYTES.len() - 1,
        );
        assert_eq!(n, 0, "cap one below image size must reject");
        assert!(raw.is_empty());
    }

    // ----- DENY_PATH_CONTAINS --------------------------------------------

    /// Every entry of `DENY_PATH_CONTAINS` produces an
    /// `OutsideAllowedPrefixes` rejection. Loops the constant so a
    /// future PR that deletes a security-critical entry (e.g.
    /// `/.ssh/`) ships a failing test.
    #[test]
    fn load_placeholder_image_rejects_every_deny_list_entry() {
        // Build a canonical that contains each needle by constructing
        // a directory tree under a tempdir. Each needle is wrapped in
        // a leading `<allowed>` and a trailing `<file>.png`. Forward
        // slashes only — the enforcement site normalises Windows
        // backslashes to forward slashes before the substring check.
        for needle in DENY_PATH_CONTAINS {
            let root = tempfile::tempdir().unwrap();
            let trimmed = needle.trim_matches('/');
            // Materialise the directory tree implied by the needle so
            // canonicalize succeeds.
            let mut current = root.path().to_path_buf();
            for segment in trimmed.split('/') {
                current = current.join(segment);
                std::fs::create_dir(&current).unwrap();
            }
            let png = write_png(&current, "x.png");
            let canon = dunce::canonicalize(&png).unwrap();
            // Allowlist is the root — without the deny-list, this
            // path would be accepted.
            let allowed = vec![dunce::canonicalize(root.path()).unwrap()];
            let err = load_placeholder_image(canon.to_str().unwrap(), &allowed).unwrap_err();
            assert!(
                matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes),
                "needle {needle:?} did not produce OutsideAllowedPrefixes; got {err:?}"
            );
        }

        // Positive control. A benign path inside an allowed
        // prefix containing **none** of the deny needles must still
        // load. Without this, a future regression that rejects every
        // path would pass the loop above and ship.
        let root = tempfile::tempdir().unwrap();
        let png = write_png(root.path(), "picture.png");
        let canon = dunce::canonicalize(&png).unwrap();
        let allowed = vec![dunce::canonicalize(root.path()).unwrap()];
        let loaded =
            load_placeholder_image(canon.to_str().unwrap(), &allowed).unwrap_or_else(|e| {
                panic!("positive control: benign path inside allowed prefix must load, got: {e:?}")
            });
        // Tighten the positive control — a regression that
        // returned an empty `LoadedPlaceholderImage` would pass a
        // bare `is_ok()` assertion. Pin the mime type and round-trip
        // the bytes against the on-disk PNG.
        assert_eq!(loaded.mime_type, "image/png");
        assert_eq!(loaded.data, PNG_BYTES);
    }
}
