use std::collections::HashMap;
use std::fmt::Write as _;

use jaq_core::load::{self, Arena, File, Loader};
use jaq_core::{Compiler, Ctx, Native, Vars, data};
use jaq_json::Val;
use serde::Serialize;
use serde_json::json;

// ---------------------------------------------------------------------------
// Filter cache
// ---------------------------------------------------------------------------

/// The `DataT` implementation used for jaq filter compilation and execution.
///
/// `JustLut<Val>` is a minimal wrapper that only provides the compiled lookup
/// table — sufficient because we don't use lifetime-dependent filters like
/// `inputs`.
type D = data::JustLut<Val>;

/// Cache of compiled jaq filters, keyed by filter source string.
///
/// The compiled `Filter` is fully owned (no lifetime parameters) and `Clone`,
/// so it can be stored directly in a `HashMap`. The `Arena` used during
/// `Loader::load` is a temporary scratch pad — once `compile` returns, the
/// `Filter` no longer borrows from it.
type JaqFilterCache = HashMap<String, jaq_core::compile::Filter<Native<D>>>;

/// Output format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Format {
    Json,
    Text,
}

impl std::fmt::Display for Format {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Format::Json => f.write_str("json"),
            Format::Text => f.write_str("text"),
        }
    }
}

/// Result of a command execution: either success (exit 0) or a user-facing error (exit 1).
/// Internal/unexpected errors are represented by `anyhow::Error` at the call site.
///
/// **Invariant**: `Success.output` must always be a valid JSON string — the pipeline handles
/// format conversion. Commands must never store pre-formatted text here.
///
/// For commands like `read` whose text output is raw file content (not structured data),
/// use `RawOutput` to bypass the JSON pipeline entirely.
#[derive(Debug)]
pub enum CommandOutcome {
    /// Successful operation — JSON output goes to stdout via the pipeline.
    Success {
        /// Always-valid JSON string (bare array, object, etc.). Never pre-formatted text.
        output: String,
        /// Optional total item count for pagination display.
        total: Option<u64>,
    },
    /// Raw text output, bypasses the JSON pipeline — printed directly to stdout as-is.
    /// Used by `read` command for text-format content output.
    RawOutput(String),
    /// User error (file not found, property missing, etc.) — output goes to stderr.
    UserError(String),
}

impl CommandOutcome {
    /// Construct a successful outcome carrying a JSON string with no total count.
    #[must_use]
    pub fn success(output: String) -> Self {
        Self::Success {
            output,
            total: None,
        }
    }

    /// Construct a successful outcome carrying a JSON string with a total item count.
    #[must_use]
    pub fn success_with_total(output: String, total: u64) -> Self {
        Self::Success {
            output,
            total: Some(total),
        }
    }

    /// Extract the output string from `Success` or `RawOutput`, or panic.
    ///
    /// Intended for use in unit tests where the command is expected to succeed.
    #[cfg(test)]
    #[must_use]
    pub fn unwrap_output(self) -> String {
        match self {
            Self::Success { output, .. } | Self::RawOutput(output) => output,
            Self::UserError(msg) => panic!("expected success, got UserError: {msg}"),
        }
    }
}

impl Format {
    #[must_use]
    pub fn from_str_opt(s: &str) -> Option<Self> {
        match s {
            "json" => Some(Self::Json),
            "text" => Some(Self::Text),
            _ => None,
        }
    }
}

/// Strip control characters that could inject terminal escape sequences.
///
/// Removes bytes 0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F, and 0x9B-0x9F
/// (C0/C1 control codes minus `\n` (0x0A) and `\t` (0x09)).
fn sanitize_control_chars(s: &str) -> String {
    s.chars()
        .filter(|&c| {
            // Keep printable chars, newline, and tab
            !c.is_control() || c == '\n' || c == '\t'
        })
        .collect()
}

/// Format a successful JSON value for output.
#[must_use]
pub fn format_success(format: Format, value: &serde_json::Value) -> String {
    match format {
        Format::Json => serde_json::to_string_pretty(value)
            .expect("serializing serde_json::Value is infallible"),
        Format::Text => {
            let mut cache = JaqFilterCache::new();
            sanitize_control_chars(&format_value_as_text(value, &mut cache))
        }
    }
}

/// Format any `Serialize` type for output.
///
/// Converts the value to `serde_json::Value` first so that the text formatter
/// can operate on a uniform representation.
#[must_use]
pub fn format_output<T: Serialize>(format: Format, value: &T) -> String {
    let json = serde_json::to_value(value).expect("derived Serialize impl should not fail");
    format_success(format, &json)
}

/// Build the JSON envelope value: `{"results": ..., "total": <optional>, "hints": [...]}`.
///
/// The envelope is always present even when hints is empty (hints becomes `[]`).
/// `total` is included only when `Some`.
#[must_use]
pub fn build_envelope_value(
    value: &serde_json::Value,
    total: Option<u64>,
    hints: &[crate::hints::Hint],
) -> serde_json::Value {
    let hints_json: Vec<serde_json::Value> = hints
        .iter()
        .map(|h| serde_json::json!({"description": &h.description, "cmd": &h.cmd}))
        .collect();
    let mut envelope = serde_json::json!({
        "results": value,
        "hints": hints_json,
    });
    if let Some(t) = total {
        envelope["total"] = serde_json::json!(t);
    }
    envelope
}

/// Format the output envelope for the user.
///
/// - **JSON**: serializes `{"results": ..., "total": <optional>, "hints": [...]}`
/// - **Text**: formats `results` as text, appends hint lines if any, adds pagination notice if needed
#[must_use]
pub fn format_envelope(
    format: Format,
    value: &serde_json::Value,
    total: Option<u64>,
    hints: &[crate::hints::Hint],
) -> String {
    match format {
        Format::Json => {
            let envelope = build_envelope_value(value, total, hints);
            serde_json::to_string_pretty(&envelope)
                .expect("serializing serde_json::Value is infallible")
        }
        Format::Text => {
            let mut cache = JaqFilterCache::new();
            let mut text = format_results_as_text(value, total, &mut cache);
            if !hints.is_empty() {
                text.push('\n');
                for hint in hints {
                    text.push_str("\n  -> ");
                    text.push_str(&hint.cmd);
                    text.push_str("  # ");
                    text.push_str(&hint.description);
                }
            }
            sanitize_control_chars(&text)
        }
    }
}

/// Format results for text output, applying pagination notice and tag-summary header.
///
/// Called by [`format_envelope`] when producing text output. The `total` is the
/// count stored in the envelope (may exceed the number of items in `results`).
fn format_results_as_text(
    results: &serde_json::Value,
    total: Option<u64>,
    cache: &mut JaqFilterCache,
) -> String {
    // Special case: array of tag summary entries ({count, name}) — reconstruct
    // the "N unique tags" header that was previously part of the TAG_SUMMARY_FILTER.
    if let (Some(total), serde_json::Value::Array(arr)) = (total, results) {
        let is_tag_array = !arr.is_empty()
            && arr.iter().all(|v| {
                v.as_object().is_some_and(|m| {
                    m.contains_key("count") && m.contains_key("name") && m.len() == 2
                })
            });
        if is_tag_array {
            let tag_label = if total == 1 { "tag" } else { "tags" };
            let header = format!("{total} unique {tag_label}");
            let entries = format_value_as_text(results, cache);
            return if entries.is_empty() {
                header
            } else {
                format!("{header}\n{entries}")
            };
        }
    }

    let text = format_value_as_text(results, cache);
    if let Some(total) = total {
        let shown = match results {
            serde_json::Value::Array(arr) => arr.len() as u64,
            _ => return text,
        };
        if shown < total {
            return format!("{text}\nshowing {shown} of {total} matches");
        }
    }
    text
}

/// Format an error for output to stderr.
#[must_use]
pub fn format_error(
    format: Format,
    error: &str,
    path: Option<&str>,
    hint: Option<&str>,
    cause: Option<&str>,
) -> String {
    match format {
        Format::Json => {
            let mut obj = json!({"error": error});
            if let Some(p) = path {
                obj["path"] = json!(p);
            }
            if let Some(h) = hint {
                obj["hint"] = json!(h);
            }
            if let Some(c) = cause {
                obj["cause"] = json!(c);
            }
            serde_json::to_string_pretty(&obj).expect("serializing serde_json::Value is infallible")
        }
        Format::Text => {
            let mut msg = format!("Error: {error}");
            if let Some(p) = path {
                let _ = write!(msg, "\n  path: {p}");
            }
            if let Some(h) = hint {
                let _ = write!(msg, "\n  hint: {h}");
            }
            if let Some(c) = cause {
                let _ = write!(msg, "\n  cause: {c}");
            }
            sanitize_control_chars(&msg)
        }
    }
}

// ---------------------------------------------------------------------------
// jq filter constants — one per output type
// ---------------------------------------------------------------------------

/// `PropertyInfo` (used by `--fields properties-typed`): `{name, type, value}`
/// When value is an array (list type), join elements with ", " for readability.
const PROPERTY_INFO_FILTER: &str = r#""\(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | join(", ")) + "]" else .value end)""#;

/// `PropertySummaryEntry`: `{count, name, type}`
const PROPERTY_SUMMARY_ENTRY_FILTER: &str =
    r#""\(.name)\t\(.type)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;

/// `TagSummary`: `{tags, total}`
const TAG_SUMMARY_FILTER: &str = r#""\(.total) unique \(if .total == 1 then "tag" else "tags" end)\n\(.tags | map("  \(.name)\t\(.count) \(if .count == 1 then "file" else "files" end)") | join("\n"))""#;

/// `TagSummaryEntry`: `{count, name}`
const TAG_SUMMARY_ENTRY_FILTER: &str =
    r#""\(.name)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;

/// `LinkInfo` — just target: `{target}`
/// Format: `  "target" (unresolved)`
const LINK_INFO_TARGET_FILTER: &str = r#""  \"\(.target)\" (unresolved)""#;

/// `LinkInfo` with path: `{path, target}`
/// Format: `  "target" → "path"`
const LINK_INFO_PATH_FILTER: &str = r#""  \"\(.target)\" → \"\(.path)\"""#;

/// `LinkInfo` with label: `{label, target}`
/// Format: `  "target" (unresolved) [label]`
const LINK_INFO_LABEL_FILTER: &str = r#""  \"\(.target)\" (unresolved) [\(.label)]""#;

/// `LinkInfo` with path and label: `{label, path, target}`
/// Format: `  "target" → "path" [label]`
const LINK_INFO_FULL_FILTER: &str = r#""  \"\(.target)\" → \"\(.path)\" [\(.label)]""#;

/// `TaskCount`: `{done, total}`
const TASK_COUNT_FILTER: &str = r#""[\(.done)/\(.total)]""#;

/// `OutlineSection` without tasks: `{code_blocks, heading, level, line, links}`
const OUTLINE_SECTION_FILTER: &str = r##""\("#" * .level) \(.heading // "(pre-heading)")\(if (.links | length) > 0 then "\n\(.links | map("  → \"\(.)\"") | join("\n"))" else "" end)""##;

/// `OutlineSection` with tasks: `{code_blocks, heading, level, line, links, tasks}`
const OUTLINE_SECTION_WITH_TASKS_FILTER: &str = r##""\("#" * .level) \(.heading // "(pre-heading)") [\(.tasks.done)/\(.tasks.total)]\(if (.links | length) > 0 then "\n\(.links | map("  → \"\(.)\"") | join("\n"))" else "" end)""##;

/// `TaskInfo`: `{done, line, status, text}`
const TASK_INFO_FILTER: &str =
    r#""line \(.line): [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;

/// `TaskReadResult`: `{done, file, line, status, text}`
const TASK_READ_RESULT_FILTER: &str =
    r#""\"\(.file)\":\(.line) [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;

/// `TaskDryRunResult`: `{done, file, line, old_status, status, text}`
/// Format: `"file":line [old] -> [new] text` — makes the direction of change
/// explicit for `task toggle --dry-run`.
const TASK_DRY_RUN_RESULT_FILTER: &str =
    r#""\"\(.file)\":\(.line) [\(.old_status)] -> [\(.status)] \(.text)""#;

/// `VaultSummary`: `{dead_ends, files, links, orphans, properties, recent_files, status, tags, tasks}`
/// Compact single-line-per-section format (~20-30 lines regardless of vault size).
const VAULT_SUMMARY_FILTER: &str = r#""Files: \(.files.total)\nDirectories: \(if (.files.directories | length) > 0 then (.files.directories | .[:7] | map("\(.directory)/ (\(.count))") | join(", ")) + (if (.files.directories | length) > 7 then ", ..." else "" end) else "(none)" end)\nProperties: \(.properties | length) — \(if (.properties | length) > 0 then (.properties | sort_by(-.count) | .[:7] | map("\(.name) (\(.count))") | join(", ")) + (if (.properties | length) > 7 then ", ..." else "" end) else "(none)" end)\nTags: \(.tags.total) — \(if (.tags.tags | length) > 0 then (.tags.tags | .[:7] | map("\(.name) (\(.count))") | join(", ")) + (if (.tags.tags | length) > 7 then ", ..." else "" end) else "(none)" end)\nTasks: \(.tasks.done)/\(.tasks.total)\nLinks: \(.links.total) total, \(.links.broken) broken\nOrphans: \(.orphans)\nDead-ends: \(.dead_ends)\nStatus: \(if (.status | length) > 0 then (.status | sort_by(-.count) | map("\(.value) (\(.count))") | join(", ")) else "(none)" end)\nRecent: \(if (.recent_files | length) > 0 then (.recent_files | map(.path) | join(", ")) else "(none)" end)""#;

/// `FindTaskInfo`: `{done, line, section, status, text}`
/// Format: `  [x] text (line N, section)` or `  [ ] text (line N, section)`
const FIND_TASK_INFO_FILTER: &str =
    r#""  [\(if .done then "x" else " " end)] \(.text) (line \(.line), \(.section))""#;

/// `ContentMatch`: `{line, section, text}`
/// Format: `  line N (section): text`
const CONTENT_MATCH_FILTER: &str = r#""  line \(.line) (\(.section)): \(.text)""#;

/// Mutation result with `property` + `value` fields:
/// covers `SetPropertyResult`, `AppendPropertyResult`, and `RemovePropertyResult` (with value).
/// Key signature: `dry_run,modified,property,scanned,skipped,total,value`
/// Format: `[dry-run] property=value: N/T modified (S scanned)` when dry-run; omits prefix otherwise.
/// Appends `(S scanned)` when not all scanned files were processed (e.g. where-filters).
const PROPERTY_VALUE_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.property)=\(.value): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map("  \"\(.)\"") | join("\n"))" else "" end)""#;

/// Mutation result with `property` only (no value field):
/// covers `RemovePropertyResult` (without value).
/// Key signature: `dry_run,modified,property,scanned,skipped,total`
/// Format: `[dry-run] property: N/T modified (S scanned)` when dry-run; omits prefix otherwise.
/// Appends `(S scanned)` when not all scanned files were processed (e.g. where-filters).
const PROPERTY_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.property): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map("  \"\(.)\"") | join("\n"))" else "" end)""#;

/// Mutation result with `tag` field:
/// covers `SetTagResult` and `RemoveTagResult`.
/// Key signature: `dry_run,modified,scanned,skipped,tag,total`
/// Format: `[dry-run] tag: N/T modified (S scanned)` when dry-run; omits prefix otherwise.
/// Appends `(S scanned)` when not all scanned files were processed (e.g. where-filters).
const TAG_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.tag): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map("  \"\(.)\"") | join("\n"))" else "" end)""#;

/// `BacklinksResult`: `{file, backlinks: [...]}`
/// Format: `N backlink(s) for "file"` with each backlink listed as `  source.md: line N`.
/// Empty case: `No backlinks found for "file"`.
const BACKLINKS_RESULT_FILTER: &str = r#"if (.backlinks | length) == 0 then "No backlinks found for \"\(.file)\"" else "\(.backlinks | length) \(if (.backlinks | length) == 1 then "backlink" else "backlinks" end) for \"\(.file)\"\n\(.backlinks | map("  \(.source): line \(.line)") | join("\n"))" end"#;

/// `LinksFix result`: `{applied, broken, case_mismatch_fixes, case_mismatches, fixable, fixes, ignored, unfixable, unfixable_links}`
/// Format: summary line with fix status. Includes case-mismatch count when non-zero.
const LINKS_FIX_FILTER: &str = r#""Broken links: \(.broken)\nFixable: \(.fixable)\nUnfixable: \(.unfixable)\nIgnored: \(.ignored)\(if .case_mismatches > 0 then "\nCase mismatches: \(.case_mismatches)" else "" end)\nApplied: \(if .applied then "yes" else "no" end)\(if (.fixes | length) > 0 then "\n\(.fixes | map("  \(.source) line \(.line): \"\(.old_target)\" → \"\(.new_target)\"") | join("\n"))" else "" end)\(if (.case_mismatch_fixes | length) > 0 then "\nCase-mismatch fixes:\n\(.case_mismatch_fixes | map("  \(.source) line \(.line): \"\(.old_target)\" → \"\(.new_target)\" [link-case-mismatch]") | join("\n"))" else "" end)""#;

/// `MvResult`: `{dry_run, from, to, total_files_updated, total_links_updated, updated_files}`
/// Format: `[dry-run] Moved <from> → <to>` with list of updated files and replacements.
const MV_RESULT_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)Moved \(.from) → \(.to)\(.updated_files | if length > 0 then "\n" + (map("  \(.file): " + (.replacements | map(.old_text + " → " + .new_text) | join(", "))) | join("\n")) else "" end)""#;

/// `ViewsListEntry`: `{filters, name}`
/// Format: `name  key=value key=value ...` — compact one-line summary of the view and its filters.
const VIEWS_LIST_ENTRY_FILTER: &str = r#""\(.name)\t\(.filters | to_entries | map("\(.key)=\(.value | if type == "array" then join(",") else tostring end)") | join(" "))""#;

/// `ViewsMutationResult`: `{action, name}`
/// Format: `action: name`
const VIEWS_MUTATION_RESULT_FILTER: &str = r#""\(.action): \(.name)""#;

// ---------------------------------------------------------------------------
// Shape-based filter lookup
// ---------------------------------------------------------------------------

/// Compute a sorted comma-joined key signature from a JSON object's top-level keys.
fn key_signature(map: &serde_json::Map<String, serde_json::Value>) -> String {
    let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
    keys.sort_unstable();
    keys.join(",")
}

/// Look up the jq filter for a given key signature.
///
/// Returns `None` for unknown shapes, which will fall back to generic formatting.
fn lookup_filter(key_sig: &str) -> Option<&'static str> {
    match key_sig {
        // PropertyInfo
        "name,type,value" => Some(PROPERTY_INFO_FILTER),
        // PropertySummaryEntry
        "count,name,type" => Some(PROPERTY_SUMMARY_ENTRY_FILTER),
        // TagSummary
        "tags,total" => Some(TAG_SUMMARY_FILTER),
        // TagSummaryEntry
        "count,name" => Some(TAG_SUMMARY_ENTRY_FILTER),
        // LinkInfo variants (optional path and label → 4 combos)
        "target" => Some(LINK_INFO_TARGET_FILTER),
        "path,target" => Some(LINK_INFO_PATH_FILTER),
        "label,target" => Some(LINK_INFO_LABEL_FILTER),
        "label,path,target" => Some(LINK_INFO_FULL_FILTER),
        // TaskCount
        "done,total" => Some(TASK_COUNT_FILTER),
        // OutlineSection (with and without tasks)
        "code_blocks,heading,level,line,links" => Some(OUTLINE_SECTION_FILTER),
        "code_blocks,heading,level,line,links,tasks" => Some(OUTLINE_SECTION_WITH_TASKS_FILTER),
        // TaskInfo
        "done,line,status,text" => Some(TASK_INFO_FILTER),
        // FindTaskInfo
        "done,line,section,status,text" => Some(FIND_TASK_INFO_FILTER),
        // ContentMatch
        "line,section,text" => Some(CONTENT_MATCH_FILTER),
        // TaskReadResult
        "done,file,line,status,text" => Some(TASK_READ_RESULT_FILTER),
        // TaskDryRunResult
        "done,file,line,old_status,status,text" => Some(TASK_DRY_RUN_RESULT_FILTER),
        // VaultSummary
        "dead_ends,files,links,orphans,properties,recent_files,status,tags,tasks"
        | "dead_ends,files,links,orphans,properties,recent_files,schema,status,tags,tasks" => {
            Some(VAULT_SUMMARY_FILTER)
        }
        // Mutation results with property + value (SetPropertyResult, AppendPropertyResult,
        // RemovePropertyResult with value)
        "dry_run,modified,property,scanned,skipped,total,value" => {
            Some(PROPERTY_VALUE_MUTATION_FILTER)
        }
        // Mutation results with property only (RemovePropertyResult without value)
        "dry_run,modified,property,scanned,skipped,total" => Some(PROPERTY_MUTATION_FILTER),
        // Mutation results with tag (SetTagResult, RemoveTagResult)
        "dry_run,modified,scanned,skipped,tag,total" => Some(TAG_MUTATION_FILTER),
        // BacklinksResult
        "backlinks,file" => Some(BACKLINKS_RESULT_FILTER),
        // LinksFix result
        "applied,broken,case_mismatch_fixes,case_mismatches,fixable,fixes,ignored,unfixable,unfixable_links" => {
            Some(LINKS_FIX_FILTER)
        }
        // MvResult
        "dry_run,from,to,total_files_updated,total_links_updated,updated_files" => {
            Some(MV_RESULT_FILTER)
        }
        // ViewsListEntry
        "filters,name" => Some(VIEWS_LIST_ENTRY_FILTER),
        // ViewsMutationResult
        "action,name" => Some(VIEWS_MUTATION_RESULT_FILTER),
        _ => None,
    }
}

// ---------------------------------------------------------------------------
// jq filter execution engine
// ---------------------------------------------------------------------------

/// Apply a jq filter string to a `serde_json::Value` and return the text output.
///
/// Looks up or compiles the filter in `cache`. Multiple outputs are joined with
/// newlines. On any error (parse or runtime), returns `None` (used internally
/// by the text formatter, which has its own fallbacks).
fn apply_jq_filter(
    filter_code: &str,
    value: &serde_json::Value,
    cache: &mut JaqFilterCache,
) -> Option<String> {
    run_jq_filter_cached(filter_code, value, cache).ok()
}

/// Apply a user-supplied jq filter to a `serde_json::Value`.
///
/// Compiles the filter on every call. For repeated use across many values,
/// prefer the cached path via [`format_success`] / [`format_value_as_text`].
///
/// Returns `Ok(String)` with newline-joined output values on success, or
/// `Err(String)` with a human-readable description of the parse or runtime error.
pub fn apply_jq_filter_result(
    filter_code: &str,
    value: &serde_json::Value,
) -> Result<String, String> {
    let filter = compile_jq_filter(filter_code)?;
    execute_jq_filter(&filter, value)
}

/// Format a jaq load error (lex/parse/IO) into a human-readable string.
///
/// `load::Error<&str>` does not implement `Display`, so we extract the first
/// error's kind and the offending source snippet manually.
fn format_load_errors(errs: &load::Errors<&str, ()>) -> String {
    // errs is Vec<(File<&str, ()>, load::Error<&str>)>
    // We take the first entry and describe its error kind.
    for (_file, err) in errs {
        match err {
            load::Error::Io(ios) => {
                if let Some((_path, msg)) = ios.first() {
                    return format!("jq filter error (IO): {msg}");
                }
            }
            load::Error::Lex(lex_errs) => {
                if let Some((expect, span)) = lex_errs.first() {
                    return format!(
                        "jq filter syntax error: expected {} near {:?}",
                        expect.as_str(),
                        span
                    );
                }
            }
            load::Error::Parse(parse_errs) => {
                if let Some((expect, _token)) = parse_errs.first() {
                    return format!("jq filter parse error: expected {}", expect.as_str());
                }
            }
        }
    }
    "jq filter error: invalid filter syntax".to_owned()
}

/// Compile a jq filter string into a reusable `Filter`.
///
/// The `Arena` used during loading is a temporary scratch pad and is dropped
/// after this function returns — the compiled `Filter` owns all its data.
fn compile_jq_filter(filter_code: &str) -> Result<jaq_core::compile::Filter<Native<D>>, String> {
    let program = File {
        code: filter_code,
        path: (),
    };
    let defs = jaq_core::defs()
        .chain(jaq_std::defs())
        .chain(jaq_json::defs());
    let loader = Loader::new(defs);
    let arena = Arena::default();

    let modules = loader
        .load(&arena, program)
        .map_err(|errs| format_load_errors(&errs))?;

    let funs = jaq_core::funs::<D>()
        .chain(jaq_std::funs::<D>())
        .chain(jaq_json::funs::<D>());
    Compiler::default()
        .with_funs(funs)
        .compile(modules)
        .map_err(|errs| {
            // compile::Errors = Vec<(File<S,P>, Vec<(S, Undefined)>)>
            // Extract the first undefined symbol name for a useful message.
            let first = errs.iter().flat_map(|(_file, undefs)| undefs.iter()).next();
            if let Some((name, undef)) = first {
                format!("jq filter error: undefined {} {:?}", undef.as_str(), name)
            } else {
                "jq filter error: compilation failed".to_owned()
            }
        })
}

/// Maximum total output size for a jq filter to prevent pathological filters
/// from causing unbounded memory growth (e.g. exponential-expansion patterns).
const JQ_OUTPUT_CAP: usize = 10 * 1024 * 1024; // 10 MiB

/// Execute a pre-compiled jq filter against a JSON value and return the text output.
fn execute_jq_filter(
    filter: &jaq_core::compile::Filter<Native<D>>,
    value: &serde_json::Value,
) -> Result<String, String> {
    let input: Val = serde_json::from_value(value.clone())
        .map_err(|e| format!("jq input conversion error: {e}"))?;
    let ctx = Ctx::<D>::new(&filter.lut, Vars::new([]));

    let mut out = String::new();
    let mut total_len: usize = 0;
    for result in filter.id.run((ctx, input)).map(jaq_core::unwrap_valr) {
        match result {
            Ok(val) => {
                let s = match val {
                    Val::TStr(ref s) | Val::BStr(ref s) => match std::str::from_utf8(s) {
                        Ok(valid) => valid.to_owned(),
                        Err(_) => String::from_utf8_lossy(s).into_owned(),
                    },
                    // For non-string values, `Display` produces valid JSON
                    // (numbers, booleans, null, arrays, objects).
                    other => other.to_string(),
                };
                // Account for the newline separator that will be prepended
                // between fragments when out is non-empty.
                total_len = total_len
                    .saturating_add(s.len())
                    .saturating_add(usize::from(!out.is_empty()));
                if total_len > JQ_OUTPUT_CAP {
                    return Err(format!(
                        "jq filter output exceeds {} MiB limit",
                        JQ_OUTPUT_CAP / (1024 * 1024)
                    ));
                }
                if !out.is_empty() {
                    out.push('\n');
                }
                out.push_str(&s);
            }
            Err(e) => return Err(format!("jq runtime error: {e}")),
        }
    }

    Ok(out)
}

/// Look up or compile a jq filter from `cache`, then execute it against `value`.
fn run_jq_filter_cached(
    filter_code: &str,
    value: &serde_json::Value,
    cache: &mut JaqFilterCache,
) -> Result<String, String> {
    if let Some(filter) = cache.get(filter_code) {
        return execute_jq_filter(filter, value);
    }
    let compiled = compile_jq_filter(filter_code)?;
    let filter = cache.entry(filter_code.to_owned()).or_insert(compiled);
    execute_jq_filter(filter, value)
}

// ---------------------------------------------------------------------------
// FileObject dynamic filter builder
// ---------------------------------------------------------------------------

/// Build a jaq filter string for a `FileObject` by inspecting which optional
/// fields are present in the JSON object.
///
/// The file header is always emitted. Each optional section (properties, tags,
/// sections, tasks, matches, links) is included only when the key is present.
///
/// **How it works:** Each part is a jaq expression that either emits a string or
/// `empty` (when the field is absent/empty). Parts are joined with `, ` — jaq's
/// alternation operator — so the filter produces one output per present section.
/// `run_jq_filter` then joins those outputs with `"\n"`, producing the final
/// multi-line text block. This coupling is intentional: changing the separator
/// in `run_jq_filter` would affect `FileObject` rendering.
fn build_file_object_filter(map: &serde_json::Map<String, serde_json::Value>) -> String {
    // Header: file path and modified timestamp — always present.
    let mut parts = vec![r#""\"\(.file)\"  (\(.modified))""#.to_owned()];

    // Title: "  title: <value>" or "  title: (none)"
    if map.contains_key("title") {
        parts.push(r#""  title: \(if .title != null then .title else "(none)" end)""#.to_owned());
    }

    // Properties: header then each as "    key: value"
    if map.contains_key("properties") {
        parts.push(
            r#"if (.properties | length) > 0 then "  properties:\n\(.properties | to_entries | map("    \(.key): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
        );
    }

    // Properties (typed): header then each as "    name (type): value"
    if map.contains_key("properties_typed") {
        parts.push(
            r#"if (.properties_typed | length) > 0 then "  properties_typed:\n\(.properties_typed | map("    \(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
        );
    }

    // Tags: "  tags: [tag1, tag2, ...]"
    if map.contains_key("tags") {
        parts.push(
            r#"if (.tags | length) > 0 then "  tags: [\(.tags | join(", "))]" else empty end"#
                .to_owned(),
        );
    }

    // Sections: header then each as "    ## Heading [done/total]" or "    ## Heading"
    // Note: uses r##"..."## because the jq filter contains the sequence "#" (hash-quoted).
    if map.contains_key("sections") {
        parts.push(
            r##"if (.sections | length) > 0 then "  sections:\n\(.sections | map("    \("#" * .level) \(.heading // "(pre-heading)")\(if .tasks then " [\(.tasks.done)/\(.tasks.total)]" else "" end)") | join("\n"))" else empty end"##.to_owned(),
        );
    }

    // Tasks: header then each as "    [x] text (line N)"
    if map.contains_key("tasks") {
        parts.push(
            r#"if (.tasks | length) > 0 then "  tasks:\n\(.tasks | map("    [\(if .done then "x" else " " end)] \(.text) (line \(.line))") | join("\n"))" else empty end"#.to_owned(),
        );
    }

    // Matches: header then each as "    line N (section): text"
    if map.contains_key("matches") {
        parts.push(
            r#"if (.matches | length) > 0 then "  matches:\n\(.matches | map("    line \(.line) (\(.section)): \(.text)") | join("\n"))" else empty end"#.to_owned(),
        );
    }

    // Score: "  score: <value>" — BM25 relevance score when pattern search was used
    if map.contains_key("score") {
        parts.push(r#""  score: \(.score)""#.to_owned());
    }

    // Links: header then each as "    \"target\" → \"path\"" or "    \"target\" (unresolved)"
    if map.contains_key("links") {
        parts.push(
            r#"if (.links | length) > 0 then "  links:\n\(.links | map("    \"\(.target)\"\(if .path then " → \"\(.path)\"" else " (unresolved)" end)") | join("\n"))" else empty end"#.to_owned(),
        );
    }

    // Backlinks: header then each as "    \"source\" line N" or "    \"source\" line N: label"
    if map.contains_key("backlinks") {
        parts.push(
            r#"if (.backlinks | length) > 0 then "  backlinks:\n\(.backlinks | map("    \"\(.source)\" line \(.line)\(if .label then ": \(.label)" else "" end)") | join("\n"))" else empty end"#.to_owned(),
        );
    }

    parts.join(", ")
}

// ---------------------------------------------------------------------------
// Text formatting
// ---------------------------------------------------------------------------

/// Format a JSON value as human-readable text using jq filters where available.
fn format_value_as_text(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
    match value {
        serde_json::Value::Array(arr) => {
            // TypeList: array of type list entries — use custom formatter with blank-line separation.
            let is_type_list = arr.first().and_then(|v| v.as_object()).is_some_and(|m| {
                key_signature(m) == "has_filename_template,property_count,required,type"
            });
            if is_type_list {
                return arr
                    .iter()
                    .filter_map(|v| v.as_object())
                    .map(format_type_list_entry_text)
                    .collect::<Vec<_>>()
                    .join("\n\n");
            }
            // Use blank-line separator between FileObjects for readability.
            let is_file_objects = arr
                .first()
                .and_then(|v| v.as_object())
                .is_some_and(|m| m.contains_key("file") && m.contains_key("modified"));
            let sep = if is_file_objects { "\n\n" } else { "\n" };
            arr.iter()
                .map(|v| format_value_as_text(v, cache))
                .collect::<Vec<_>>()
                .join(sep)
        }
        serde_json::Value::Object(map) => {
            let sig = key_signature(map);
            if let Some(filter) = lookup_filter(&sig)
                && let Some(output) = apply_jq_filter(filter, value, cache)
            {
                return output;
            }
            // TypeShow: detected by presence of "properties" object + "required" array + "type" string.
            if sig == "defaults,filename_template,properties,required,type" {
                return format_type_show_text(map);
            }
            // LintOutput: detected by "files" array of {file, violations} + "total".
            if map.contains_key("total")
                && map.contains_key("files")
                && let Some(serde_json::Value::Array(arr)) = map.get("files")
            {
                let is_lint = arr
                    .first()
                    .and_then(|v| v.as_object())
                    .is_some_and(|m| m.contains_key("file") && m.contains_key("violations"))
                    || arr.is_empty();
                if is_lint {
                    return format_lint_output_text(map);
                }
            }
            // FileObject: dynamically compose filter from present fields.
            if map.contains_key("file") && map.contains_key("modified") {
                let filter = build_file_object_filter(map);
                if let Some(output) = apply_jq_filter(&filter, value, cache) {
                    return output;
                }
            }
            // Fallback: generic key: value lines
            format_object_generic(map, cache)
        }
        other => format_scalar(other, cache),
    }
}

/// Format `LintOutput` JSON as human-readable text.
///
/// Reproduces the format previously generated by `commands::lint::format_text_output`.
fn format_lint_output_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
    use std::fmt::Write as _;

    let mut s = String::new();
    let dry_run = map
        .get("dry_run")
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(false);

    // Fix actions (shown first).
    if let Some(fixes_arr) = map.get("fixes").and_then(|f| f.as_array()) {
        let verb = if dry_run { "Would fix" } else { "Fixed" };
        for file_fix in fixes_arr {
            let file = file_fix
                .get("file")
                .and_then(serde_json::Value::as_str)
                .unwrap_or("?");
            let actions = file_fix.get("actions").and_then(|a| a.as_array());
            let Some(actions) = actions else { continue };
            if actions.is_empty() {
                continue;
            }
            let _ = writeln!(s, "{verb} {file}:");
            for a in actions {
                let kind = a
                    .get("kind")
                    .and_then(serde_json::Value::as_str)
                    .unwrap_or("");
                let property = a
                    .get("property")
                    .and_then(serde_json::Value::as_str)
                    .unwrap_or("?");
                let new = a
                    .get("new")
                    .and_then(serde_json::Value::as_str)
                    .unwrap_or("");
                let old = a.get("old").and_then(serde_json::Value::as_str);
                match (kind, old) {
                    ("insert-default", _) => {
                        let _ = writeln!(s, "  insert  {property} = {new:?}");
                    }
                    ("infer-type", _) => {
                        let _ = writeln!(s, "  infer   type = {new:?}");
                    }
                    ("fix-enum-typo", Some(old_v)) => {
                        let _ = writeln!(s, "  enum    {property}: {old_v:?} -> {new:?}");
                    }
                    ("normalize-date", Some(old_v)) => {
                        let _ = writeln!(s, "  date    {property}: {old_v:?} -> {new:?}");
                    }
                    _ => {
                        let _ = writeln!(s, "  {kind}  {property} = {new:?}");
                    }
                }
            }
        }
    }

    // File violations.
    let files = map.get("files").and_then(|f| f.as_array());
    if let Some(files) = files {
        for file_entry in files {
            let file = file_entry
                .get("file")
                .and_then(serde_json::Value::as_str)
                .unwrap_or("?");
            let violations = file_entry.get("violations").and_then(|v| v.as_array());
            let Some(violations) = violations else {
                continue;
            };
            if violations.is_empty() {
                continue;
            }
            let _ = writeln!(s, "{file}:");
            for v in violations {
                let severity = v
                    .get("severity")
                    .and_then(serde_json::Value::as_str)
                    .unwrap_or("warn");
                let message = v
                    .get("message")
                    .and_then(serde_json::Value::as_str)
                    .unwrap_or("");
                let pad = if severity == "error" {
                    "error"
                } else {
                    "warn "
                };
                let _ = writeln!(s, "  {pad}  {message}");
            }
        }
    }

    let error_count: u64 = map
        .get("errors")
        .and_then(serde_json::Value::as_u64)
        .unwrap_or(0);
    let warn_count: u64 = map
        .get("warnings")
        .and_then(serde_json::Value::as_u64)
        .unwrap_or(0);
    let files_with_issues: u64 = map
        .get("files_with_issues")
        .and_then(serde_json::Value::as_u64)
        .unwrap_or(0);
    let limited = map
        .get("limited")
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(false);
    let shown_files = map
        .get("files")
        .and_then(|f| f.as_array())
        .map_or(0, |arr| {
            arr.iter()
                .filter(|e| {
                    e.get("violations")
                        .and_then(|v| v.as_array())
                        .is_some_and(|v| !v.is_empty())
                })
                .count()
        });

    if limited {
        let _ = writeln!(
            s,
            "… (showing {shown_files} of {files_with_issues} files with issues)"
        );
    }

    // Summary line.
    let files_checked: u64 = map
        .get("files_checked")
        .and_then(serde_json::Value::as_u64)
        .unwrap_or(0);
    let files_label = if files_checked == 1 { "file" } else { "files" };
    if error_count == 0 && warn_count == 0 {
        let _ = write!(s, "{files_checked} {files_label} checked, no issues");
    } else {
        let _ = write!(
            s,
            "{files_checked} {files_label} checked, {files_with_issues} with issues ({error_count} errors, {warn_count} warnings)",
        );
    }

    let fix_count: usize = map
        .get("fixes")
        .and_then(|f| f.as_array())
        .map_or(0, |arr| {
            arr.iter()
                .filter_map(|f| f.get("actions").and_then(|a| a.as_array()).map(Vec::len))
                .sum()
        });
    if fix_count > 0 {
        let fixed_label = if dry_run { "would fix" } else { "fixed" };
        let _ = write!(s, " — {fixed_label} {fix_count}");
    }

    s
}

/// Format a `types show` result as human-readable text.
///
/// Expected JSON shape: `{type, required, filename_template, defaults, properties}`.
/// Output example:
/// ```text
/// Type: iteration
///
/// Required: title, type, date
///
/// Properties:
///   branch:
///     type: string
///     pattern: ^iter-\d+/
///
///   date:
///     type: date
///
/// Filename template: iteration-{N}-{slug}.md
/// ```
fn format_type_show_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
    use std::fmt::Write as _;

    let mut s = String::new();

    let type_name = map
        .get("type")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("?");
    let _ = write!(s, "Type: {type_name}");

    // Required fields.
    if let Some(serde_json::Value::Array(req)) = map.get("required")
        && !req.is_empty()
    {
        let list: Vec<&str> = req.iter().filter_map(serde_json::Value::as_str).collect();
        let _ = write!(s, "\n\nRequired: {}", list.join(", "));
    }

    // Defaults block.
    if let Some(serde_json::Value::Object(defaults)) = map.get("defaults")
        && !defaults.is_empty()
    {
        let _ = write!(s, "\n\nDefaults:");
        let mut keys: Vec<&str> = defaults.keys().map(String::as_str).collect();
        keys.sort_unstable();
        for key in keys {
            if let Some(value) = defaults.get(key) {
                let display = match value {
                    serde_json::Value::String(sv) => sv.clone(),
                    other => other.to_string(),
                };
                let _ = write!(s, "\n  {key}: {display}");
            }
        }
    }

    // Properties block.
    if let Some(serde_json::Value::Object(props)) = map.get("properties")
        && !props.is_empty()
    {
        let _ = write!(s, "\n\nProperties:");
        let mut prop_names: Vec<&str> = props.keys().map(String::as_str).collect();
        prop_names.sort_unstable();
        for name in prop_names {
            let Some(prop_val) = props.get(name) else {
                continue;
            };
            let _ = write!(s, "\n  {name}:");
            if let Some(obj) = prop_val.as_object() {
                // Print each constraint key on its own indented line.
                // Always show "type" first, then remaining keys sorted.
                let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
                keys.sort_unstable_by(|a, b| {
                    if *a == "type" {
                        std::cmp::Ordering::Less
                    } else if *b == "type" {
                        std::cmp::Ordering::Greater
                    } else {
                        a.cmp(b)
                    }
                });
                for key in keys {
                    if let Some(v) = obj.get(key) {
                        let display = match v {
                            serde_json::Value::Array(arr) => arr
                                .iter()
                                .filter_map(serde_json::Value::as_str)
                                .collect::<Vec<_>>()
                                .join(", "),
                            serde_json::Value::String(sv) => sv.clone(),
                            other => other.to_string(),
                        };
                        let _ = write!(s, "\n    {key}: {display}");
                    }
                }
            }
            s.push('\n'); // blank line between property blocks
        }
    }

    // Optional filename template.
    if let Some(serde_json::Value::String(tmpl)) = map.get("filename_template") {
        let _ = write!(s, "\nFilename template: {tmpl}");
    }

    s
}

/// Format a single `types list` entry as human-readable text.
///
/// Expected JSON shape: `{type, required, property_count, has_filename_template}`.
/// Output example:
/// ```text
/// iteration (4 required, 6 properties)
///   required: title, type, date, tags
/// ```
///
/// Note: `has_filename_template` is a boolean; the actual template is only in `types show`.
/// When present, a hint to run `types show` is appended.
fn format_type_list_entry_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
    use std::fmt::Write as _;

    let mut s = String::new();

    let type_name = map
        .get("type")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("?");

    let req_arr: &[serde_json::Value] = map
        .get("required")
        .and_then(serde_json::Value::as_array)
        .map_or(&[], Vec::as_slice);
    let req_count = req_arr.len();

    let prop_count = map
        .get("property_count")
        .and_then(serde_json::Value::as_u64)
        .unwrap_or(0);

    let has_filename = map
        .get("has_filename_template")
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(false);

    let prop_label = if prop_count == 1 {
        "property"
    } else {
        "properties"
    };
    let _ = write!(
        s,
        "{type_name} ({prop_count} {prop_label}, {req_count} required)"
    );

    if !req_arr.is_empty() {
        let list: Vec<&str> = req_arr
            .iter()
            .filter_map(serde_json::Value::as_str)
            .collect();
        let _ = write!(s, "\n  required: {}", list.join(", "));
    }

    if has_filename {
        let _ = write!(s, "\n  filename: (see type details)");
    }

    s
}

/// Generic key: value rendering for unknown object shapes.
fn format_object_generic(
    map: &serde_json::Map<String, serde_json::Value>,
    cache: &mut JaqFilterCache,
) -> String {
    map.iter()
        .map(|(k, v)| format!("{k}: {}", format_value_as_text(v, cache)))
        .collect::<Vec<_>>()
        .join("\n")
}

/// Format a scalar JSON value as text.
fn format_scalar(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
    match value {
        serde_json::Value::String(s) => s.clone(),
        serde_json::Value::Number(n) => n.to_string(),
        serde_json::Value::Bool(b) => b.to_string(),
        serde_json::Value::Null => "null".to_owned(),
        serde_json::Value::Array(arr) => {
            let items: Vec<String> = arr.iter().map(|v| format_scalar(v, cache)).collect();
            items.join(", ")
        }
        serde_json::Value::Object(_) => format_value_as_text(value, cache),
    }
}

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

    // Convenience wrappers so individual tests don't have to construct a cache.
    fn jq(filter: &str, val: &serde_json::Value) -> Option<String> {
        apply_jq_filter(filter, val, &mut JaqFilterCache::new())
    }

    fn fmt(val: &serde_json::Value) -> String {
        format_value_as_text(val, &mut JaqFilterCache::new())
    }

    fn scalar(val: &serde_json::Value) -> String {
        format_scalar(val, &mut JaqFilterCache::new())
    }

    // --- error formatting ---

    #[test]
    fn format_json_error() {
        let out = format_error(
            Format::Json,
            "file not found",
            Some("foo/bar"),
            Some("did you mean foo/bar.md?"),
            None,
        );
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["error"], "file not found");
        assert_eq!(parsed["hint"], "did you mean foo/bar.md?");
        assert!(parsed.get("cause").is_none());
    }

    #[test]
    fn format_text_error() {
        let out = format_error(Format::Text, "file not found", Some("foo"), None, None);
        assert!(out.contains("Error: file not found"));
        assert!(out.contains("path: foo"));
    }

    #[test]
    fn format_json_success() {
        let val = json!({"name": "test", "value": 42});
        let out = format_success(Format::Json, &val);
        assert!(out.contains("\"name\": \"test\""));
    }

    // --- apply_jq_filter ---

    #[test]
    fn apply_jq_filter_simple() {
        let val = json!({"name": "hello", "count": 3});
        let result = jq(r#""\(.name): \(.count)""#, &val);
        assert_eq!(result.as_deref(), Some("hello: 3"));
    }

    #[test]
    fn apply_jq_filter_array_map() {
        let val = json!(["a", "b", "c"]);
        let result = jq(".[]", &val);
        assert_eq!(result.as_deref(), Some("a\nb\nc"));
    }

    #[test]
    fn apply_jq_filter_invalid_returns_none() {
        let val = json!({"x": 1});
        let result = jq("this is not valid jq %%%", &val);
        assert!(result.is_none());
    }

    // --- jq output size cap ---

    #[test]
    fn jq_output_cap_constant_is_10_mib() {
        assert_eq!(JQ_OUTPUT_CAP, 10 * 1024 * 1024);
    }

    #[test]
    fn jq_output_within_cap_succeeds() {
        // A small output must pass through without hitting the cap.
        let val = json!({"msg": "hello"});
        let result = apply_jq_filter_result(".msg", &val);
        assert_eq!(result.as_deref(), Ok("hello"));
    }

    #[test]
    fn jq_output_cap_triggers_on_large_output() {
        // Build a JSON array large enough to exceed JQ_OUTPUT_CAP when expanded.
        // Each element is "aaaa...a" (1000 chars). 11_000 elements = 11 MB > 10 MB cap.
        let big_string = "a".repeat(1000);
        let val = serde_json::Value::Array(
            std::iter::repeat_n(serde_json::Value::String(big_string), 11_000).collect(),
        );
        // ".[]" emits each element as a separate output value.
        let result = apply_jq_filter_result(".[]", &val);
        assert!(result.is_err(), "expected cap error but got Ok output");
        let err = result.unwrap_err();
        assert!(
            err.contains("exceeds") && err.contains("MiB"),
            "unexpected error message: {err}"
        );
    }

    // --- property type filters ---

    #[test]
    fn property_info_filter() {
        let val = json!({"name": "title", "type": "text", "value": "My Note"});
        let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
        assert!(out.contains("title"));
        assert!(out.contains("text"));
        assert!(out.contains("My Note"));
    }

    #[test]
    fn property_info_filter_list_value() {
        let val = json!({"name": "tags", "type": "list", "value": ["rust", "cli"]});
        let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
        assert!(out.contains("tags"));
        assert!(out.contains("list"));
        // Array values should be wrapped in brackets and joined with ", "
        assert!(out.contains("[rust, cli]"), "expected [rust, cli]: {out}");
        assert!(!out.contains("[\"rust\""));
    }

    #[test]
    fn property_summary_entry_filter() {
        let val = json!({"count": 7, "name": "title", "type": "text"});
        let out = jq(PROPERTY_SUMMARY_ENTRY_FILTER, &val).unwrap();
        assert!(out.contains("title"));
        assert!(out.contains("text"));
        assert!(out.contains("7 files"));
    }

    #[test]
    fn tag_summary_filter() {
        let val = json!({
            "tags": [{"name": "rust", "count": 3}, {"name": "cli", "count": 1}],
            "total": 2
        });
        let out = jq(TAG_SUMMARY_FILTER, &val).unwrap();
        assert!(out.contains("2 unique tags"));
        assert!(out.contains("rust"));
        assert!(out.contains("3 files"));
    }

    // --- link type filters ---

    #[test]
    fn link_info_target_only_filter() {
        let val = json!({"target": "broken-link"});
        let out = jq(LINK_INFO_TARGET_FILTER, &val).unwrap();
        assert!(out.contains("broken-link"));
        assert!(out.contains("unresolved"));
    }

    #[test]
    fn link_info_with_path_filter() {
        let val = json!({"path": "note-b.md", "target": "note-b"});
        let out = jq(LINK_INFO_PATH_FILTER, &val).unwrap();
        assert!(out.contains("note-b"));
        assert!(out.contains("note-b.md"));
    }

    // --- outline type filters ---

    #[test]
    fn task_count_filter() {
        let val = json!({"done": 3, "total": 5});
        let out = jq(TASK_COUNT_FILTER, &val).unwrap();
        assert_eq!(out, "[3/5]");
    }

    #[test]
    fn outline_section_filter() {
        let val = json!({
            "code_blocks": [],
            "heading": "Introduction",
            "level": 1,
            "line": 5,
            "links": ["[[other]]"]
        });
        let out = jq(OUTLINE_SECTION_FILTER, &val).unwrap();
        assert!(out.contains('#'));
        assert!(out.contains("Introduction"));
        assert!(out.contains("[[other]]"));
    }

    #[test]
    fn outline_section_with_tasks_filter() {
        let val = json!({
            "code_blocks": [],
            "heading": "Tasks",
            "level": 2,
            "line": 10,
            "links": [],
            "tasks": {"done": 2, "total": 4}
        });
        let out = jq(OUTLINE_SECTION_WITH_TASKS_FILTER, &val).unwrap();
        assert!(out.contains("##"));
        assert!(out.contains("Tasks"));
        assert!(out.contains("[2/4]"));
    }

    // --- FindTaskInfo filter ---

    #[test]
    fn find_task_info_filter_done() {
        let val = json!({
            "done": true,
            "line": 42,
            "section": "Implementation",
            "status": "x",
            "text": "Write the tests"
        });
        let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
        assert!(out.contains("[x]"));
        assert!(out.contains("Write the tests"));
        assert!(out.contains("line 42"));
        assert!(out.contains("Implementation"));
    }

    #[test]
    fn find_task_info_filter_not_done() {
        let val = json!({
            "done": false,
            "line": 7,
            "section": "Todo",
            "status": " ",
            "text": "Review PR"
        });
        let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
        assert!(out.contains("[ ]"));
        assert!(out.contains("Review PR"));
        assert!(out.contains("line 7"));
        assert!(out.contains("Todo"));
    }

    #[test]
    fn find_task_info_via_format_value_as_text() {
        // Verify that format_value_as_text dispatches to the correct filter.
        let val = json!({
            "done": true,
            "line": 5,
            "section": "Goals",
            "status": "x",
            "text": "Ship it"
        });
        let out = fmt(&val);
        assert!(out.contains("[x]"));
        assert!(out.contains("Ship it"));
        assert!(
            !out.contains("done: true"),
            "should not use generic fallback"
        );
    }

    // --- ContentMatch filter ---

    #[test]
    fn content_match_filter() {
        let val = json!({
            "line": 15,
            "section": "Background",
            "text": "This is the matching line"
        });
        let out = jq(CONTENT_MATCH_FILTER, &val).unwrap();
        assert!(out.contains("line 15"));
        assert!(out.contains("Background"));
        assert!(out.contains("This is the matching line"));
    }

    #[test]
    fn content_match_via_format_value_as_text() {
        let val = json!({
            "line": 3,
            "section": "Intro",
            "text": "hello world"
        });
        let out = fmt(&val);
        assert!(out.contains("line 3"));
        assert!(out.contains("hello world"));
        assert!(!out.contains("line: 3"), "should not use generic fallback");
    }

    // --- Mutation result filters ---

    #[test]
    fn property_value_mutation_filter_with_modified() {
        // SetPropertyResult / AppendPropertyResult / RemovePropertyResult (with value)
        // scanned == total: no "(N scanned)" suffix
        let val = json!({
            "modified": ["note-a.md", "note-b.md"],
            "property": "status",
            "scanned": 2,
            "skipped": [],
            "total": 2,
            "value": "done"
        });
        let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
        assert!(out.contains("status=done"));
        assert!(out.contains("2/2 modified"));
        assert!(
            !out.contains("scanned"),
            "no scanned suffix when scanned == total"
        );
        assert!(out.contains("note-a.md"));
        assert!(out.contains("note-b.md"));
    }

    #[test]
    fn property_value_mutation_filter_all_skipped() {
        let val = json!({
            "modified": [],
            "property": "priority",
            "scanned": 1,
            "skipped": ["note-a.md"],
            "total": 1,
            "value": "high"
        });
        let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
        assert!(out.contains("priority=high"));
        assert!(out.contains("0/1 modified"));
        // No file paths should appear when nothing was modified
        assert!(!out.contains("note-a.md"));
    }

    #[test]
    fn property_value_mutation_filter_with_where_filter() {
        // scanned > total: "(N scanned)" suffix should appear
        let val = json!({
            "modified": ["note-a.md"],
            "property": "status",
            "scanned": 5,
            "skipped": [],
            "total": 1,
            "value": "done"
        });
        let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
        assert!(out.contains("status=done"));
        assert!(out.contains("1/1 modified"));
        assert!(out.contains("(5 scanned)"));
    }

    #[test]
    fn property_value_mutation_via_format_value_as_text() {
        let val = json!({
            "dry_run": false,
            "modified": ["notes/a.md"],
            "property": "status",
            "scanned": 1,
            "skipped": [],
            "total": 1,
            "value": "done"
        });
        let out = fmt(&val);
        assert!(out.contains("status=done"));
        assert!(
            !out.contains("modified: "),
            "should not use generic fallback"
        );
    }

    #[test]
    fn property_mutation_filter_no_value() {
        // RemovePropertyResult without value; scanned == total
        let val = json!({
            "dry_run": false,
            "modified": ["note.md"],
            "property": "draft",
            "scanned": 1,
            "skipped": [],
            "total": 1
        });
        let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
        assert!(out.contains("draft"));
        assert!(out.contains("1/1 modified"));
        assert!(
            !out.contains("scanned"),
            "no scanned suffix when scanned == total"
        );
        assert!(out.contains("note.md"));
    }

    #[test]
    fn property_mutation_filter_no_value_with_where_filter() {
        // RemovePropertyResult without value; scanned > total
        let val = json!({
            "dry_run": false,
            "modified": ["note.md"],
            "property": "draft",
            "scanned": 7,
            "skipped": [],
            "total": 1
        });
        let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
        assert!(out.contains("draft"));
        assert!(out.contains("1/1 modified"));
        assert!(out.contains("(7 scanned)"));
    }

    #[test]
    fn tag_mutation_filter_with_modified() {
        // SetTagResult / RemoveTagResult; scanned == total
        let val = json!({
            "dry_run": false,
            "modified": ["a.md", "b.md"],
            "scanned": 3,
            "skipped": ["c.md"],
            "tag": "rust",
            "total": 3
        });
        let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
        assert!(out.contains("rust"));
        assert!(out.contains("2/3 modified"));
        assert!(
            !out.contains("scanned"),
            "no scanned suffix when scanned == total"
        );
        assert!(out.contains("a.md"));
        assert!(out.contains("b.md"));
        assert!(!out.contains("c.md"));
    }

    #[test]
    fn tag_mutation_filter_with_where_filter() {
        // scanned > total: "(N scanned)" suffix
        let val = json!({
            "dry_run": false,
            "modified": ["a.md"],
            "scanned": 10,
            "skipped": [],
            "tag": "rust",
            "total": 1
        });
        let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
        assert!(out.contains("rust"));
        assert!(out.contains("1/1 modified"));
        assert!(out.contains("(10 scanned)"));
    }

    #[test]
    fn tag_mutation_via_format_value_as_text() {
        let val = json!({
            "dry_run": false,
            "modified": [],
            "scanned": 1,
            "skipped": ["note.md"],
            "tag": "cli",
            "total": 1
        });
        let out = fmt(&val);
        assert!(out.contains("cli"));
        assert!(!out.contains("tag: cli"), "should not use generic fallback");
    }

    // --- dry-run prefix in text output ---

    #[test]
    fn property_value_mutation_dry_run_prefix() {
        let val = json!({
            "dry_run": true,
            "modified": ["note.md"],
            "property": "status",
            "scanned": 1,
            "skipped": [],
            "total": 1,
            "value": "done"
        });
        let out = fmt(&val);
        assert!(
            out.contains("[dry-run] status=done"),
            "dry-run prefix missing: {out}"
        );
    }

    #[test]
    fn tag_mutation_dry_run_prefix() {
        let val = json!({
            "dry_run": true,
            "modified": ["note.md"],
            "scanned": 1,
            "skipped": [],
            "tag": "rust",
            "total": 1
        });
        let out = fmt(&val);
        assert!(
            out.contains("[dry-run] rust"),
            "dry-run prefix missing: {out}"
        );
    }

    #[test]
    fn property_value_mutation_no_dry_run_prefix() {
        let val = json!({
            "dry_run": false,
            "modified": ["note.md"],
            "property": "status",
            "scanned": 1,
            "skipped": [],
            "total": 1,
            "value": "done"
        });
        let out = fmt(&val);
        assert!(
            !out.contains("[dry-run]"),
            "should not have dry-run prefix: {out}"
        );
    }

    // --- build_file_object_filter ---

    #[test]
    fn build_file_object_filter_minimal() {
        // Only the required `file` and `modified` fields.
        let map: serde_json::Map<String, serde_json::Value> =
            serde_json::from_str(r#"{"file": "notes/foo.md", "modified": "2024-01-01"}"#).unwrap();
        let filter = build_file_object_filter(&map);
        let val = json!({"file": "notes/foo.md", "modified": "2024-01-01"});
        let out = jq(&filter, &val).unwrap();
        assert!(out.contains("notes/foo.md"));
        assert!(out.contains("2024-01-01"));
    }

    #[test]
    fn build_file_object_filter_with_tags() {
        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
            r#"{"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]}"#,
        )
        .unwrap();
        let filter = build_file_object_filter(&map);
        let val = json!({"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]});
        let out = jq(&filter, &val).unwrap();
        assert!(out.contains("foo.md"));
        assert!(out.contains("tags: [rust, cli]"));
    }

    #[test]
    fn build_file_object_filter_with_properties() {
        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
            r#"{"file": "foo.md", "modified": "2024-01-01", "properties": {"status": "done"}}"#,
        )
        .unwrap();
        let filter = build_file_object_filter(&map);
        let val = json!({
            "file": "foo.md",
            "modified": "2024-01-01",
            "properties": {"status": "done"}
        });
        let out = jq(&filter, &val).unwrap();
        assert!(out.contains("foo.md"));
        assert!(out.contains("properties:"));
        assert!(out.contains("status: done"));
    }

    #[test]
    fn build_file_object_filter_with_tasks() {
        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
            r#"{"file": "foo.md", "modified": "2024-01-01", "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]}"#,
        )
        .unwrap();
        let filter = build_file_object_filter(&map);
        let val = json!({
            "file": "foo.md",
            "modified": "2024-01-01",
            "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]
        });
        let out = jq(&filter, &val).unwrap();
        assert!(out.contains("foo.md"));
        assert!(out.contains("tasks:"));
        assert!(out.contains("[x] Ship it"));
        assert!(out.contains("line 5"));
    }

    #[test]
    fn build_file_object_filter_with_sections() {
        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
            r#"{"file": "foo.md", "modified": "2024-01-01", "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]}"#,
        )
        .unwrap();
        let filter = build_file_object_filter(&map);
        let val = json!({
            "file": "foo.md",
            "modified": "2024-01-01",
            "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]
        });
        let out = jq(&filter, &val).unwrap();
        assert!(out.contains("foo.md"));
        assert!(out.contains("sections:"));
        assert!(out.contains("# Intro"));
    }

    #[test]
    fn build_file_object_filter_with_matches() {
        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
            r#"{"file": "foo.md", "modified": "2024-01-01", "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]}"#,
        )
        .unwrap();
        let filter = build_file_object_filter(&map);
        let val = json!({
            "file": "foo.md",
            "modified": "2024-01-01",
            "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]
        });
        let out = jq(&filter, &val).unwrap();
        assert!(out.contains("foo.md"));
        assert!(out.contains("matches:"));
        assert!(out.contains("line 3 (Intro): hello world"));
    }

    #[test]
    fn build_file_object_filter_with_links() {
        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
            r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "bar", "path": "bar.md"}]}"#,
        )
        .unwrap();
        let filter = build_file_object_filter(&map);
        let val = json!({
            "file": "foo.md",
            "modified": "2024-01-01",
            "links": [{"target": "bar", "path": "bar.md"}]
        });
        let out = jq(&filter, &val).unwrap();
        assert!(out.contains("foo.md"));
        assert!(out.contains("links:"));
        assert!(out.contains(r#""bar" → "bar.md""#));
    }

    #[test]
    fn build_file_object_filter_unresolved_link() {
        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
            r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "missing"}]}"#,
        )
        .unwrap();
        let filter = build_file_object_filter(&map);
        let val = json!({
            "file": "foo.md",
            "modified": "2024-01-01",
            "links": [{"target": "missing"}]
        });
        let out = jq(&filter, &val).unwrap();
        assert!(out.contains(r#""missing" (unresolved)"#));
    }

    // --- FileObject text rendering through format_value_as_text ---

    #[test]
    fn file_object_text_rendering_minimal() {
        let val = json!({"file": "notes/foo.md", "modified": "2024-01-15"});
        let out = fmt(&val);
        assert!(out.contains("notes/foo.md"));
        assert!(out.contains("2024-01-15"));
        // Should not look like generic fallback
        assert!(!out.contains("file: notes/foo.md"));
    }

    #[test]
    fn file_object_text_rendering_full() {
        let val = json!({
            "file": "notes/project.md",
            "modified": "2024-03-01",
            "tags": ["rust", "work"],
            "properties": {"status": "active"},
            "tasks": [
                {"done": false, "line": 10, "section": "Todo", "status": " ", "text": "Fix bug"},
                {"done": true, "line": 20, "section": "Done", "status": "x", "text": "Write docs"}
            ]
        });
        let out = fmt(&val);
        assert!(out.contains("notes/project.md"));
        assert!(out.contains("properties:"));
        assert!(out.contains("status: active"));
        assert!(out.contains("tags: [rust, work]"));
        assert!(out.contains("tasks:"));
        assert!(out.contains("[ ] Fix bug"));
        assert!(out.contains("[x] Write docs"));
    }

    // --- Array of FileObjects with blank-line separator ---

    #[test]
    fn array_of_file_objects_uses_blank_line_separator() {
        let val = json!([
            {"file": "a.md", "modified": "2024-01-01"},
            {"file": "b.md", "modified": "2024-01-02"}
        ]);
        let out = fmt(&val);
        assert!(out.contains("a.md"));
        assert!(out.contains("b.md"));
        // Should have a blank line between entries
        assert!(
            out.contains("\n\n"),
            "expected blank-line separator between file objects"
        );
    }

    #[test]
    fn array_of_non_file_objects_uses_single_newline() {
        let val = json!([
            {"count": 1, "name": "status", "type": "text"},
            {"count": 3, "name": "title", "type": "text"}
        ]);
        let out = fmt(&val);
        assert!(out.contains("status"));
        assert!(out.contains("title"));
        // Should NOT have a blank line separator
        assert!(
            !out.contains("\n\n"),
            "non-file-objects should use single newline"
        );
    }

    // --- format_scalar nested object delegation ---

    #[test]
    fn format_scalar_delegates_nested_objects() {
        // A nested object with a known shape should get its filter applied,
        // not the k=v flat format.
        let inner = json!({"count": 2, "name": "status", "type": "text"});
        let out = scalar(&inner);
        // Should NOT look like the old "count=2, name=status, type=text" format.
        assert!(
            !out.contains("count=2"),
            "should delegate to format_value_as_text"
        );
        // Should look like the PropertySummaryEntry filter output.
        assert!(out.contains("status"));
        assert!(out.contains("2 files"));
    }

    // --- format_value_as_text integration ---

    #[test]
    fn format_value_as_text_uses_filter_for_known_shape() {
        // PropertySummaryEntry has a known shape: {count, name, type}
        let val = json!({"count": 3, "name": "status", "type": "text"});
        let out = fmt(&val);
        assert!(out.contains("status"));
        assert!(out.contains("3 files"));
        // Should NOT look like "count: 3" (that's the generic fallback)
        assert!(!out.contains("count: 3"));
    }

    #[test]
    fn format_value_as_text_falls_back_for_unknown_shape() {
        let val = json!({"foo": "bar", "baz": 42});
        let out = fmt(&val);
        // Generic fallback: key: value
        assert!(out.contains("foo: bar") || out.contains("baz: 42"));
    }

    #[test]
    fn mv_result_filter_applied() {
        let val = json!({
            "dry_run": false,
            "from": "sub/b.md",
            "to": "archive/b.md",
            "total_files_updated": 1,
            "total_links_updated": 1,
            "updated_files": [
                {
                    "file": "a.md",
                    "replacements": [
                        {"old_text": "[[sub/b]]", "new_text": "[[archive/b]]", "line": 1}
                    ]
                }
            ]
        });
        // Verify key signature matches expected
        let sig = {
            let map = val.as_object().unwrap();
            let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
            keys.sort_unstable();
            keys.join(",")
        };
        assert_eq!(
            sig,
            "dry_run,from,to,total_files_updated,total_links_updated,updated_files"
        );
        // Verify the jq filter itself works
        let filter_result = apply_jq_filter_result(MV_RESULT_FILTER, &val);
        assert!(filter_result.is_ok(), "filter error: {filter_result:?}");
        let out = filter_result.unwrap();
        assert!(out.contains("Moved sub/b.md"), "out: {out}");
        assert!(out.contains("archive/b.md"), "out: {out}");
        assert!(out.contains("[[sub/b]]"), "out: {out}");
        assert!(out.contains("[[archive/b]]"), "out: {out}");
        // Verify lookup_filter finds the filter for this shape
        let found_filter =
            lookup_filter("dry_run,from,to,total_files_updated,total_links_updated,updated_files");
        assert!(
            found_filter.is_some(),
            "lookup_filter returned None for MvResult shape"
        );
        // format_value_as_text should pick up the filter
        let formatted = fmt(&val);
        assert!(
            formatted.contains("Moved sub/b.md"),
            "formatted: {formatted}"
        );
    }

    #[test]
    fn format_value_as_text_array_of_typed_objects() {
        let val = json!([
            {"path": "a.md", "tags": ["rust"]},
            {"path": "b.md", "tags": ["cli"]}
        ]);
        let out = fmt(&val);
        assert!(out.contains("a.md"));
        assert!(out.contains("b.md"));
        assert!(out.contains("rust"));
        assert!(out.contains("cli"));
    }

    // --- sanitize_control_chars ---

    #[test]
    fn sanitize_control_chars_strips_escape_sequences() {
        let input = "Hello\x1b[31mRED\x1b[0m World";
        let output = sanitize_control_chars(input);
        assert!(
            !output.contains('\x1b'),
            "escape sequences should be stripped"
        );
        assert!(output.contains("Hello"));
        assert!(output.contains("RED"));
        assert!(output.contains("World"));
    }

    #[test]
    fn sanitize_control_chars_preserves_newline_and_tab() {
        let input = "line1\nline2\ttabbed";
        let output = sanitize_control_chars(input);
        assert_eq!(output, input);
    }

    #[test]
    fn text_output_sanitizes_escape_sequences() {
        let value = serde_json::json!({
            "results": {
                "title": "Hello\x1b[31mRED\x1b[0m World",
                "file": "test\x1b[2J.md"
            }
        });
        let output = format_success(Format::Text, &value);
        assert!(
            !output.contains('\x1b'),
            "escape sequences should be stripped"
        );
        assert!(output.contains("Hello") && output.contains("World"));
    }
}
