use anyhow::Result;

use crate::cli::{InspectCommand, InspectionFormat, SchemaCommand, TablesCommand};
use crate::input::parse_input_specs;

pub(crate) fn run_tables_command(cmd: TablesCommand) -> Result<()> {
    let input_specs = parse_input_specs(&cmd.input, cmd.sheet.as_deref())?;
    let workbook_inputs = build_workbook_inputs(&input_specs);
    let summaries = query_forge::load_table_summaries(&workbook_inputs, 0, false)?;

    let output = match cmd.format {
        InspectionFormat::Text => render_tables_text(&summaries),
        InspectionFormat::Json => render_tables_json(&summaries),
        InspectionFormat::Markdown => render_tables_markdown(&summaries),
    };

    println!("{output}");
    Ok(())
}

pub(crate) fn run_schema_command(cmd: SchemaCommand) -> Result<()> {
    let input_specs = parse_input_specs(&cmd.input, cmd.sheet.as_deref())?;
    let workbook_inputs = build_workbook_inputs(&input_specs);
    let summaries = query_forge::load_table_summaries(&workbook_inputs, 0, false)?;

    let output = match cmd.format {
        InspectionFormat::Text => render_schema_text(&summaries),
        InspectionFormat::Json => render_schema_json(&summaries),
        InspectionFormat::Markdown => render_schema_markdown(&summaries),
    };

    println!("{output}");
    Ok(())
}

pub(crate) fn run_inspect_command(cmd: InspectCommand) -> Result<()> {
    let input_specs = parse_input_specs(&cmd.input, cmd.sheet.as_deref())?;
    let workbook_inputs = build_workbook_inputs(&input_specs);
    let summaries = query_forge::load_table_summaries(&workbook_inputs, cmd.sample, cmd.stats)?;

    let output = match cmd.format {
        InspectionFormat::Text => render_inspect_text(&summaries, cmd.stats),
        InspectionFormat::Json => render_inspect_json(&summaries, cmd.stats),
        InspectionFormat::Markdown => render_inspect_markdown(&summaries, cmd.stats),
    };

    println!("{output}");
    Ok(())
}

// ─── helpers ────────────────────────────────────────────────────────────────

fn build_workbook_inputs<'a>(
    specs: &'a [crate::input::InputSpec],
) -> Vec<query_forge::WorkbookInput<'a>> {
    specs
        .iter()
        .map(|s| query_forge::WorkbookInput {
            path: s.path.as_path(),
            sheet_name: s.sheet_name.as_deref(),
            table_name: s.table_name.as_deref(),
        })
        .collect()
}

// ─── tables ─────────────────────────────────────────────────────────────────

pub(crate) fn render_tables_text(summaries: &[query_forge::TableSummary]) -> String {
    if summaries.is_empty() {
        return String::new();
    }

    let col_table = "table";
    let col_source = "source";
    let col_selector = "selector";
    let col_rows = "rows";
    let col_columns = "columns";

    let w_table = summaries
        .iter()
        .map(|s| s.table_name.len())
        .max()
        .unwrap_or(0)
        .max(col_table.len());
    let w_source = summaries
        .iter()
        .map(|s| s.source_path.len())
        .max()
        .unwrap_or(0)
        .max(col_source.len());
    let w_selector = summaries
        .iter()
        .map(|s| s.source_selector.as_deref().unwrap_or("").len())
        .max()
        .unwrap_or(0)
        .max(col_selector.len());
    let w_rows = summaries
        .iter()
        .map(|s| s.row_count.to_string().len())
        .max()
        .unwrap_or(0)
        .max(col_rows.len());
    let w_columns = summaries
        .iter()
        .map(|s| s.columns.len().to_string().len())
        .max()
        .unwrap_or(0)
        .max(col_columns.len());

    let mut out = String::new();
    out.push_str(&format!(
        "{:<w_table$}  {:<w_source$}  {:<w_selector$}  {:>w_rows$}  {:>w_columns$}",
        col_table, col_source, col_selector, col_rows, col_columns,
    ));
    out.push('\n');
    out.push_str(&format!(
        "{}  {}  {}  {}  {}",
        "-".repeat(w_table),
        "-".repeat(w_source),
        "-".repeat(w_selector),
        "-".repeat(w_rows),
        "-".repeat(w_columns),
    ));

    for s in summaries {
        out.push('\n');
        out.push_str(&format!(
            "{:<w_table$}  {:<w_source$}  {:<w_selector$}  {:>w_rows$}  {:>w_columns$}",
            s.table_name,
            s.source_path,
            s.source_selector.as_deref().unwrap_or(""),
            s.row_count,
            s.columns.len(),
        ));
    }

    out
}

pub(crate) fn render_tables_json(summaries: &[query_forge::TableSummary]) -> String {
    let mut out = String::from("[\n");
    for (i, s) in summaries.iter().enumerate() {
        if i > 0 {
            out.push_str(",\n");
        }
        let selector = s
            .source_selector
            .as_deref()
            .map(json_string)
            .unwrap_or_else(|| "null".to_owned());
        out.push_str(&format!(
            "  {{\"table\":{},\"source\":{},\"selector\":{},\"rows\":{},\"columns\":{}}}",
            json_string(&s.table_name),
            json_string(&s.source_path),
            selector,
            s.row_count,
            s.columns.len(),
        ));
    }
    out.push_str("\n]");
    out
}

pub(crate) fn render_tables_markdown(summaries: &[query_forge::TableSummary]) -> String {
    let mut out = String::from("| table | source | selector | rows | columns |\n");
    out.push_str("| --- | --- | --- | --- | --- |\n");
    for s in summaries {
        out.push_str(&format!(
            "| {} | {} | {} | {} | {} |\n",
            md_cell(&s.table_name),
            md_cell(&s.source_path),
            md_cell(s.source_selector.as_deref().unwrap_or("")),
            s.row_count,
            s.columns.len(),
        ));
    }
    out.trim_end_matches('\n').to_owned()
}

// ─── schema ──────────────────────────────────────────────────────────────────

pub(crate) fn render_schema_text(summaries: &[query_forge::TableSummary]) -> String {
    if summaries.is_empty() {
        return String::new();
    }

    let col_table = "table";
    let col_column = "column";
    let col_type = "type";
    let col_nullable = "nullable";

    let all_cols: Vec<&query_forge::ColumnInfo> =
        summaries.iter().flat_map(|s| &s.columns).collect();

    let w_table = all_cols
        .iter()
        .map(|c| c.table_name.len())
        .max()
        .unwrap_or(0)
        .max(col_table.len());
    let w_column = all_cols
        .iter()
        .map(|c| c.column_name.len())
        .max()
        .unwrap_or(0)
        .max(col_column.len());
    let w_type = all_cols
        .iter()
        .map(|c| c.inferred_type.as_str().len())
        .max()
        .unwrap_or(0)
        .max(col_type.len());
    let w_nullable = col_nullable.len();

    let mut out = String::new();
    out.push_str(&format!(
        "{:<w_table$}  {:<w_column$}  {:<w_type$}  {:<w_nullable$}",
        col_table, col_column, col_type, col_nullable,
    ));
    out.push('\n');
    out.push_str(&format!(
        "{}  {}  {}  {}",
        "-".repeat(w_table),
        "-".repeat(w_column),
        "-".repeat(w_type),
        "-".repeat(w_nullable),
    ));

    for c in all_cols {
        out.push('\n');
        out.push_str(&format!(
            "{:<w_table$}  {:<w_column$}  {:<w_type$}  {:<w_nullable$}",
            c.table_name,
            c.column_name,
            c.inferred_type.as_str(),
            if c.nullable { "true" } else { "false" },
        ));
    }

    out
}

pub(crate) fn render_schema_json(summaries: &[query_forge::TableSummary]) -> String {
    let mut out = String::from("[\n");
    let mut first = true;
    for s in summaries {
        for c in &s.columns {
            if !first {
                out.push_str(",\n");
            }
            first = false;
            out.push_str(&format!(
                "  {{\"table\":{},\"column\":{},\"type\":{},\"nullable\":{}}}",
                json_string(&c.table_name),
                json_string(&c.column_name),
                json_string(c.inferred_type.as_str()),
                c.nullable,
            ));
        }
    }
    out.push_str("\n]");
    out
}

pub(crate) fn render_schema_markdown(summaries: &[query_forge::TableSummary]) -> String {
    let mut out = String::from("| table | column | type | nullable |\n");
    out.push_str("| --- | --- | --- | --- |\n");
    for s in summaries {
        for c in &s.columns {
            out.push_str(&format!(
                "| {} | {} | {} | {} |\n",
                md_cell(&c.table_name),
                md_cell(&c.column_name),
                c.inferred_type.as_str(),
                c.nullable,
            ));
        }
    }
    out.trim_end_matches('\n').to_owned()
}

// ─── inspect ─────────────────────────────────────────────────────────────────

pub(crate) fn render_inspect_text(
    summaries: &[query_forge::TableSummary],
    include_stats: bool,
) -> String {
    let mut out = String::new();

    for (idx, s) in summaries.iter().enumerate() {
        if idx > 0 {
            out.push('\n');
        }

        // Header
        let selector_part = s
            .source_selector
            .as_deref()
            .map(|sel| format!(":{sel}"))
            .unwrap_or_default();
        out.push_str(&format!(
            "=== {} ({}{}) ===\n",
            s.table_name, s.source_path, selector_part
        ));
        out.push_str(&format!(
            "Rows: {}  Columns: {}\n",
            s.row_count,
            s.columns.len()
        ));

        // Schema
        if !s.columns.is_empty() {
            out.push('\n');
            out.push_str("Schema:\n");
            let w_name = s
                .columns
                .iter()
                .map(|c| c.column_name.len())
                .max()
                .unwrap_or(0);
            let w_type = s
                .columns
                .iter()
                .map(|c| c.inferred_type.as_str().len())
                .max()
                .unwrap_or(0);
            for (i, c) in s.columns.iter().enumerate() {
                let nullability = if c.nullable { "nullable" } else { "not null" };
                let stats_part = if include_stats {
                    s.column_stats
                        .as_ref()
                        .and_then(|cs| cs.get(i))
                        .map(|cs| {
                            let mut parts = vec![format!("distinct: {}", cs.distinct_count)];
                            if let Some(min) = &cs.min_value {
                                parts.push(format!("min: {min}"));
                            }
                            if let Some(max) = &cs.max_value {
                                parts.push(format!("max: {max}"));
                            }
                            format!("  ({})", parts.join(", "))
                        })
                        .unwrap_or_default()
                } else {
                    String::new()
                };
                out.push_str(&format!(
                    "  {:<w_name$}  {:<w_type$}  {}{}",
                    c.column_name,
                    c.inferred_type.as_str(),
                    nullability,
                    stats_part,
                ));
                out.push('\n');
            }
        }

        // Sample rows
        if !s.sample_rows.is_empty() {
            out.push('\n');
            out.push_str(&format!("Sample ({} rows):\n", s.sample_rows.len()));
            let col_names: Vec<&str> = s.columns.iter().map(|c| c.column_name.as_str()).collect();
            let col_count = col_names.len();

            // Compute column widths
            let mut widths: Vec<usize> = col_names.iter().map(|h| h.len()).collect();
            for row in &s.sample_rows {
                for (j, val) in row.iter().enumerate() {
                    if j < widths.len() {
                        widths[j] = widths[j].max(display_query_value(val).len());
                    }
                }
            }

            // Header line
            let header_line: String = col_names
                .iter()
                .enumerate()
                .map(|(j, h)| format!("{:w$}", h, w = widths[j]))
                .collect::<Vec<_>>()
                .join(" | ");
            out.push_str(&format!("  {header_line}\n"));

            // Separator
            let sep_line: String = widths
                .iter()
                .map(|w| "-".repeat(*w))
                .collect::<Vec<_>>()
                .join("-+-");
            out.push_str(&format!("  {sep_line}\n"));

            // Data rows
            for row in &s.sample_rows {
                let row_line: String = (0..col_count)
                    .map(|j| {
                        let val = row.get(j).map_or(String::new(), display_query_value);
                        format!("{:w$}", val, w = widths[j])
                    })
                    .collect::<Vec<_>>()
                    .join(" | ");
                out.push_str(&format!("  {row_line}\n"));
            }
        }

        // Warnings
        if !s.warnings.is_empty() {
            out.push('\n');
            out.push_str("Warnings:\n");
            for w in &s.warnings {
                out.push_str(&format!("  ! {w}\n"));
            }
        }
    }

    out.trim_end_matches('\n').to_owned()
}

pub(crate) fn render_inspect_json(
    summaries: &[query_forge::TableSummary],
    include_stats: bool,
) -> String {
    let mut out = String::from("[\n");
    for (i, s) in summaries.iter().enumerate() {
        if i > 0 {
            out.push_str(",\n");
        }
        let selector = s
            .source_selector
            .as_deref()
            .map(json_string)
            .unwrap_or_else(|| "null".to_owned());

        out.push_str("  {\n");
        out.push_str(&format!("    \"table\":{},\n", json_string(&s.table_name)));
        out.push_str(&format!(
            "    \"source\":{},\n",
            json_string(&s.source_path)
        ));
        out.push_str(&format!("    \"selector\":{selector},\n"));
        out.push_str(&format!("    \"rows\":{},\n", s.row_count));
        out.push_str(&format!("    \"columns\":{},\n", s.columns.len()));

        // schema
        out.push_str("    \"schema\":[\n");
        for (j, c) in s.columns.iter().enumerate() {
            if j > 0 {
                out.push_str(",\n");
            }
            if include_stats {
                let stats_part = s
                    .column_stats
                    .as_ref()
                    .and_then(|cs| cs.get(j))
                    .map(|cs| {
                        let min = cs
                            .min_value
                            .as_deref()
                            .map(json_string)
                            .unwrap_or_else(|| "null".to_owned());
                        let max = cs
                            .max_value
                            .as_deref()
                            .map(json_string)
                            .unwrap_or_else(|| "null".to_owned());
                        format!(
                            ",\"distinct\":{},\"min\":{min},\"max\":{max}",
                            cs.distinct_count
                        )
                    })
                    .unwrap_or_default();
                out.push_str(&format!(
                    "      {{\"column\":{},\"type\":{},\"nullable\":{}{}}}",
                    json_string(&c.column_name),
                    json_string(c.inferred_type.as_str()),
                    c.nullable,
                    stats_part,
                ));
            } else {
                out.push_str(&format!(
                    "      {{\"column\":{},\"type\":{},\"nullable\":{}}}",
                    json_string(&c.column_name),
                    json_string(c.inferred_type.as_str()),
                    c.nullable,
                ));
            }
        }
        out.push_str("\n    ],\n");

        // sample
        out.push_str("    \"sample\":[\n");
        for (r, row) in s.sample_rows.iter().enumerate() {
            if r > 0 {
                out.push_str(",\n");
            }
            out.push_str("      {");
            for (j, c) in s.columns.iter().enumerate() {
                if j > 0 {
                    out.push(',');
                }
                let val = row.get(j);
                let json_val = match val {
                    Some(query_forge::QueryValue::Null) | None => "null".to_owned(),
                    Some(query_forge::QueryValue::Integer(n)) => n.to_string(),
                    Some(query_forge::QueryValue::Real(n)) => n.to_string(),
                    Some(query_forge::QueryValue::Text(t)) => json_string(t),
                };
                out.push_str(&format!("{}:{json_val}", json_string(&c.column_name)));
            }
            out.push('}');
        }
        out.push_str("\n    ],\n");

        // warnings
        out.push_str("    \"warnings\":[\n");
        for (w, warning) in s.warnings.iter().enumerate() {
            if w > 0 {
                out.push_str(",\n");
            }
            out.push_str(&format!("      {}", json_string(warning)));
        }
        out.push_str("\n    ]\n");

        out.push_str("  }");
    }
    out.push_str("\n]");
    out
}

pub(crate) fn render_inspect_markdown(
    summaries: &[query_forge::TableSummary],
    include_stats: bool,
) -> String {
    let mut out = String::new();

    for (idx, s) in summaries.iter().enumerate() {
        if idx > 0 {
            out.push_str("\n\n");
        }

        let selector_part = s
            .source_selector
            .as_deref()
            .map(|sel| format!(":{sel}"))
            .unwrap_or_default();
        out.push_str(&format!(
            "### {} (`{}{}`)\n\n",
            s.table_name, s.source_path, selector_part
        ));
        out.push_str(&format!(
            "Rows: {} | Columns: {}\n\n",
            s.row_count,
            s.columns.len()
        ));

        if !s.columns.is_empty() {
            if include_stats {
                out.push_str("| column | type | nullable | distinct | min | max |\n");
                out.push_str("| --- | --- | --- | --- | --- | --- |\n");
                for (j, c) in s.columns.iter().enumerate() {
                    let cs = s.column_stats.as_ref().and_then(|cs| cs.get(j));
                    let distinct = cs.map_or("-".to_owned(), |cs| cs.distinct_count.to_string());
                    let min = cs.and_then(|cs| cs.min_value.as_deref()).unwrap_or("-");
                    let max = cs.and_then(|cs| cs.max_value.as_deref()).unwrap_or("-");
                    out.push_str(&format!(
                        "| {} | {} | {} | {} | {} | {} |\n",
                        md_cell(&c.column_name),
                        c.inferred_type.as_str(),
                        c.nullable,
                        distinct,
                        md_cell(min),
                        md_cell(max),
                    ));
                }
            } else {
                out.push_str("| column | type | nullable |\n");
                out.push_str("| --- | --- | --- |\n");
                for c in &s.columns {
                    out.push_str(&format!(
                        "| {} | {} | {} |\n",
                        md_cell(&c.column_name),
                        c.inferred_type.as_str(),
                        c.nullable,
                    ));
                }
            }
            out.push('\n');
        }

        if !s.sample_rows.is_empty() {
            let headers: Vec<String> = s.columns.iter().map(|c| md_cell(&c.column_name)).collect();
            out.push_str(&format!("| {} |\n", headers.join(" | ")));
            out.push_str(&format!(
                "| {} |\n",
                s.columns
                    .iter()
                    .map(|_| "---")
                    .collect::<Vec<_>>()
                    .join(" | ")
            ));
            for row in &s.sample_rows {
                let cells: Vec<String> = (0..s.columns.len())
                    .map(|j| md_cell(&row.get(j).map_or(String::new(), display_query_value)))
                    .collect();
                out.push_str(&format!("| {} |\n", cells.join(" | ")));
            }
            out.push('\n');
        }

        if !s.warnings.is_empty() {
            out.push_str("**Warnings:**\n\n");
            for w in &s.warnings {
                out.push_str(&format!("- ⚠️ {w}\n"));
            }
        }
    }

    out.trim_end_matches('\n').to_owned()
}

// ─── shared utilities ────────────────────────────────────────────────────────

fn display_query_value(val: &query_forge::QueryValue) -> String {
    match val {
        query_forge::QueryValue::Null => String::new(),
        query_forge::QueryValue::Integer(n) => n.to_string(),
        query_forge::QueryValue::Real(n) => n.to_string(),
        query_forge::QueryValue::Text(t) => t.clone(),
    }
}

fn json_string(s: &str) -> String {
    let mut out = String::from('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if c <= '\u{1F}' => {
                let _ = std::fmt::Write::write_fmt(&mut out, format_args!("\\u{:04x}", c as u32));
            }
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

fn md_cell(s: &str) -> String {
    s.replace('|', "\\|").replace('\n', "<br>")
}

// ─── tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use query_forge::{ColumnInfo, ColumnStats, InferredType, QueryValue, TableSummary};

    fn make_summary(name: &str, src: &str, selector: Option<&str>) -> TableSummary {
        TableSummary {
            table_name: name.to_owned(),
            source_path: src.to_owned(),
            source_selector: selector.map(str::to_owned),
            row_count: 2,
            columns: vec![
                ColumnInfo {
                    table_name: name.to_owned(),
                    column_name: "id".to_owned(),
                    inferred_type: InferredType::Integer,
                    nullable: false,
                },
                ColumnInfo {
                    table_name: name.to_owned(),
                    column_name: "name".to_owned(),
                    inferred_type: InferredType::Text,
                    nullable: false,
                },
                ColumnInfo {
                    table_name: name.to_owned(),
                    column_name: "price".to_owned(),
                    inferred_type: InferredType::Real,
                    nullable: true,
                },
            ],
            sample_rows: vec![
                vec![
                    QueryValue::Integer(1),
                    QueryValue::Text("Keyboard".to_owned()),
                    QueryValue::Real(12.5),
                ],
                vec![
                    QueryValue::Integer(2),
                    QueryValue::Text("Cable".to_owned()),
                    QueryValue::Null,
                ],
            ],
            warnings: vec![],
            column_stats: None,
        }
    }

    #[test]
    fn tables_text_contains_header_and_rows() {
        let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let out = render_tables_text(&[s]);
        assert!(out.contains("table"), "should contain 'table' header");
        assert!(out.contains("sales"), "should contain table name");
        assert!(out.contains("./sales.xlsx"), "should contain source path");
        assert!(out.contains("Q1"), "should contain selector");
        assert!(out.contains("2"), "should contain row count");
    }

    #[test]
    fn tables_json_is_valid_structure() {
        let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let out = render_tables_json(&[s]);
        assert!(out.starts_with('['), "should be JSON array");
        assert!(out.contains("\"table\":\"sales\""));
        assert!(out.contains("\"selector\":\"Q1\""));
        assert!(out.contains("\"rows\":2"));
    }

    #[test]
    fn tables_json_null_selector_when_none() {
        let s = make_summary("costs", "./costs.csv", None);
        let out = render_tables_json(&[s]);
        assert!(out.contains("\"selector\":null"));
    }

    #[test]
    fn tables_markdown_has_pipe_rows() {
        let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let out = render_tables_markdown(&[s]);
        assert!(out.starts_with("| table |"), "should have header row");
        assert!(out.contains("| sales |"));
    }

    #[test]
    fn schema_text_contains_all_columns() {
        let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let out = render_schema_text(&[s]);
        assert!(out.contains("id"), "should list id column");
        assert!(out.contains("name"), "should list name column");
        assert!(out.contains("price"), "should list price column");
        assert!(out.contains("INTEGER"), "should show INTEGER type");
        assert!(out.contains("TEXT"), "should show TEXT type");
        assert!(out.contains("REAL"), "should show REAL type");
        assert!(out.contains("true"), "should show nullable true for price");
        assert!(out.contains("false"), "should show nullable false for id");
    }

    #[test]
    fn schema_json_lists_all_columns() {
        let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let out = render_schema_json(&[s]);
        assert!(out.contains("\"column\":\"id\""));
        assert!(out.contains("\"type\":\"INTEGER\""));
        assert!(out.contains("\"nullable\":false"));
        assert!(out.contains("\"nullable\":true"));
    }

    #[test]
    fn schema_markdown_has_pipe_rows() {
        let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let out = render_schema_markdown(&[s]);
        assert!(out.starts_with("| table |"), "should have table header");
        assert!(out.contains("| sales | id | INTEGER | false |"));
    }

    #[test]
    fn inspect_text_has_section_headers() {
        let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let out = render_inspect_text(&[s], false);
        assert!(out.contains("=== sales"), "should have section header");
        assert!(out.contains("Rows: 2"), "should show row count");
        assert!(out.contains("Schema:"), "should have schema section");
        assert!(out.contains("Sample ("), "should have sample section");
        assert!(out.contains("Keyboard"), "should show sample data");
    }

    #[test]
    fn inspect_text_with_stats_shows_extra_info() {
        let mut s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        s.column_stats = Some(vec![
            ColumnStats {
                distinct_count: 2,
                min_value: Some("1".to_owned()),
                max_value: Some("2".to_owned()),
            },
            ColumnStats {
                distinct_count: 2,
                min_value: None,
                max_value: None,
            },
            ColumnStats {
                distinct_count: 1,
                min_value: Some("12.5".to_owned()),
                max_value: Some("12.5".to_owned()),
            },
        ]);
        let out = render_inspect_text(&[s], true);
        assert!(out.contains("distinct: 2"), "should show distinct count");
        assert!(out.contains("min: 1"), "should show min value");
        assert!(out.contains("max: 2"), "should show max value");
    }

    #[test]
    fn inspect_json_has_all_sections() {
        let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let out = render_inspect_json(&[s], false);
        assert!(out.contains("\"schema\":["), "should have schema section");
        assert!(out.contains("\"sample\":["), "should have sample section");
        assert!(
            out.contains("\"warnings\":["),
            "should have warnings section"
        );
    }

    #[test]
    fn inspect_markdown_has_headings() {
        let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let out = render_inspect_markdown(&[s], false);
        assert!(out.contains("### sales"), "should have markdown heading");
        assert!(out.contains("Rows: 2"), "should show row count");
    }

    #[test]
    fn inspect_text_shows_warnings() {
        let mut s = make_summary("sales", "./sales.xlsx", None);
        s.warnings.push("column 'price' has mixed types".to_owned());
        let out = render_inspect_text(&[s], false);
        assert!(out.contains("Warnings:"), "should have warnings section");
        assert!(out.contains("mixed types"), "should show warning text");
    }

    #[test]
    fn multi_table_inspect_json_is_array() {
        let s1 = make_summary("sales", "./sales.xlsx", Some("Q1"));
        let s2 = make_summary("costs", "./costs.csv", None);
        let out = render_inspect_json(&[s1, s2], false);
        assert!(out.starts_with('['));
        assert!(out.contains("\"table\":\"sales\""));
        assert!(out.contains("\"table\":\"costs\""));
    }
}
