//! Pretty-table rendering for the output panel
//! (ADR-0016).
//!
//! Two public entry points: [`render_data_table`] for query
//! / show-data / auto-show outputs, and [`render_structure`]
//! for table-structure listings produced after DDL or
//! `show table`.
//!
//! Both return a `Vec<String>` — one display row per element
//! — to match the existing `OutputLine`-per-line discipline
//! in `app.rs`. Border chars are Unicode box-drawing
//! (UTF-8); no ASCII fallback (ADR-0016 OOS-5).
//!
//! Layout: outer frame + header underline only. No per-row
//! horizontal rules, which keeps typical row counts (5–50)
//! readable without visual noise.
//!
//! NULL renders as the literal `(null)`; cell newlines, tabs
//! and control characters render as `↵`, `→`, `·`
//! respectively (display-only — underlying data is
//! untouched).

use std::collections::HashSet;

use crate::app::{OutputKind, OutputLine, OutputSpan, OutputStyleClass};
use crate::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription};
use crate::dsl::Type;
use crate::mode::Mode;

const NULL_DISPLAY: &str = "(null)";

/// Render a query / data result as a Vec of display rows.
///
/// Empty result sets yield the header + a `(no rows)` line
/// inside the frame — matches the existing UX hint that "the
/// query ran but there was nothing to show."
#[must_use]
pub fn render_data_table(data: &DataResult) -> Vec<String> {
    let header_cells: Vec<String> = data.columns.clone();
    let alignments: Vec<Alignment> = data
        .column_types
        .iter()
        .map(|t| alignment_for(*t))
        .collect();

    let body: Vec<Vec<String>> = if data.rows.is_empty() {
        // For empty tables, still render the header band so
        // the user sees the column shape, then a single
        // `(no rows)` row spanning all columns. We achieve
        // the spanning effect with a left-aligned cell in
        // the first column and empty cells elsewhere.
        let mut row = vec![String::new(); header_cells.len().max(1)];
        if let Some(first) = row.first_mut() {
            *first = "(no rows)".to_string();
        }
        vec![row]
    } else {
        data.rows
            .iter()
            .map(|r| {
                r.iter()
                    .map(|c| {
                        c.as_ref()
                            .map_or_else(|| NULL_DISPLAY.to_string(), |s| sanitize_cell(s))
                    })
                    .collect()
            })
            .collect()
    };

    render_table(&header_cells, &body, &alignments)
}

/// Render a table-structure listing.
///
/// Produces a header line (`<TableName>`), the schema table
/// itself, and — for a structure that has FK relationships
/// — `References:` / `Referenced by:` blocks below as plain
/// indented text (relationship visualization is its own
/// future ADR per §5 OOS-1).
#[must_use]
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    out.push(desc.name.clone());

    let header_cells = vec![
        "Name".to_string(),
        "Type".to_string(),
        "Constraints".to_string(),
    ];
    let body: Vec<Vec<String>> = desc
        .columns
        .iter()
        .map(|c| {
            vec![
                c.name.clone(),
                type_display(c),
                constraints_display(c),
            ]
        })
        .collect();
    // Type column gets the same numeric/text rule as data
    // columns by virtue of consistency, but every entry is
    // a keyword string ("text", "serial", …) so left-align
    // is correct in every case. Constraints are similarly
    // textual.
    let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
    out.extend(render_table(&header_cells, &body, &alignments));

    if !desc.outbound_relationships.is_empty() {
        out.push("References:".to_string());
        for r in &desc.outbound_relationships {
            out.push(format!(
                "  {} → {}.{}  ({}, on delete {}, on update {})",
                r.local_column,
                r.other_table,
                r.other_column,
                r.name,
                r.on_delete,
                r.on_update,
            ));
        }
    }
    if !desc.inbound_relationships.is_empty() {
        out.push("Referenced by:".to_string());
        for r in &desc.inbound_relationships {
            out.push(format!(
                "  {}.{} → {}  ({}, on delete {}, on update {})",
                r.other_table,
                r.other_column,
                r.local_column,
                r.name,
                r.on_delete,
                r.on_update,
            ));
        }
    }

    // Indexes section (ADR-0025), shown only when the table
    // carries at least one user-created index. A UNIQUE index is
    // marked `[unique]` so a learner can tell a uniqueness-enforcing
    // index from a performance-only one (ADR-0035 §4d).
    if !desc.indexes.is_empty() {
        out.push("Indexes:".to_string());
        for index in &desc.indexes {
            let unique = if index.unique { " [unique]" } else { "" };
            out.push(format!(
                "  {} ({}){unique}",
                index.name,
                index.columns.join(", "),
            ));
        }
    }

    // Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)`
    // and table `CHECK (…)` constraints. Single-column UNIQUE / NOT NULL /
    // PK / column-level CHECK already show in the per-column "Constraints"
    // column above; this section is the table-level constraints that span
    // columns or stand alone. A named CHECK shows its name.
    if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() {
        out.push("Table constraints:".to_string());
        for cols in &desc.unique_constraints {
            // Annotate with the derived, addressable name (ADR-0035
            // Amendment 1) so the user can `drop constraint <name>`.
            out.push(format!(
                "  {}: unique ({})",
                crate::db::unique_constraint_name(cols),
                cols.join(", ")
            ));
        }
        for chk in &desc.check_constraints {
            match &chk.name {
                Some(name) => out.push(format!("  check {name} ({})", chk.expr)),
                None => out.push(format!("  check ({})", chk.expr)),
            }
        }
    }

    out
}

/// The plan annotation taxonomy (ADR-0028 §4).
///
/// A `detail` string is classified by the **first** entry
/// whose marker substring it contains; the marker's byte range
/// is also the run that carries the category colour (ADR-0028
/// §6 — "only the category-bearing keywords"). Order matters:
/// the automatic-index markers come before the plain-index and
/// `SCAN` ones so a `SEARCH … USING AUTOMATIC … INDEX` is not
/// mis-read as a plain index search or a full scan; `SCAN`
/// comes last so a covering-index scan (`SCAN … USING INDEX …`)
/// classifies on its index, not on the `SCAN`.
///
/// A `detail` matching no marker renders neutral — the engine's
/// plan vocabulary may grow (ADR-0028 §4).
const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[
    ("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex),
    ("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex),
    ("USING COVERING INDEX", OutputStyleClass::Efficient),
    ("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient),
    ("USING PRIMARY KEY", OutputStyleClass::Efficient),
    ("USING INDEX", OutputStyleClass::Efficient),
    ("USE TEMP B-TREE", OutputStyleClass::Expensive),
    ("SCAN", OutputStyleClass::Expensive),
];

/// The short "you should add an index here" tag appended to an
/// automatic-index plan node (ADR-0028 §6 — the distinct
/// marker that makes it read as advice, not merely "slow").
const AUTO_INDEX_TAG: &str = "  ← add an index?";

/// Render a captured query plan as a box-drawing tree
/// (ADR-0028 §3/§4).
///
/// The standard-SQL display form of the explained statement is
/// shown first; the plan tree follows, built from each row's
/// `id` / `parent` links. Node text is the engine's `detail`
/// string **verbatim** — nothing is reworded — but each
/// node's category-bearing keywords carry a semantic colour
/// from the annotation taxonomy.
///
/// Unlike the `Vec<String>`-returning renderers above this
/// returns ready-built `OutputLine`s, because a plan line
/// carries per-span styling (ADR-0028 §5). Each line is
/// stamped with `mode` for the submission-mode tag.
#[must_use]
pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec<OutputLine> {
    let mut out: Vec<OutputLine> = Vec::with_capacity(plan.rows.len() + 1);
    // The display SQL is wholly neutral — no category keywords.
    out.push(neutral_plan_line(plan.display_sql.clone(), mode));
    // `emitted` guards against a malformed plan with a cyclic
    // or self-referential `parent` link — every node is drawn
    // at most once.
    let mut emitted: HashSet<i64> = HashSet::new();
    render_plan_subtree(&plan.rows, 0, "", &mut out, &mut emitted, mode);
    out
}

/// Append the subtree rooted at `parent` (`0` = top level) to
/// `out`, drawing box-drawing connectors. `prefix` is the
/// indent accumulated from ancestor levels.
fn render_plan_subtree(
    rows: &[ExplainRow],
    parent: i64,
    prefix: &str,
    out: &mut Vec<OutputLine>,
    emitted: &mut HashSet<i64>,
    mode: Mode,
) {
    let children: Vec<&ExplainRow> =
        rows.iter().filter(|r| r.parent == parent).collect();
    let last_idx = children.len().saturating_sub(1);
    for (idx, row) in children.iter().enumerate() {
        if !emitted.insert(row.id) {
            continue;
        }
        let is_last = idx == last_idx;
        let connector = if is_last { "└─ " } else { "├─ " };
        out.push(plan_node_line(prefix, connector, &row.detail, mode));
        let child_prefix =
            format!("{prefix}{}", if is_last { "   " } else { "│  " });
        render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
    }
}

/// Classify `detail` against the taxonomy, returning the byte
/// offset + length of the matched marker and its colour class.
/// `None` when no marker matches (a neutral node).
fn classify_detail(detail: &str) -> Option<(usize, usize, OutputStyleClass)> {
    PLAN_TAXONOMY
        .iter()
        .find_map(|(marker, class)| detail.find(marker).map(|off| (off, marker.len(), *class)))
}

/// Build one plan-tree node line: `{prefix}{connector}{detail}`
/// with the connectors / prefix / table & index names neutral
/// and only the category-bearing keyword run coloured
/// (ADR-0028 §6). An automatic-index node also gets the
/// "add an index?" advice tag.
fn plan_node_line(prefix: &str, connector: &str, detail: &str, mode: Mode) -> OutputLine {
    let mut text = format!("{prefix}{connector}{detail}");
    let detail_start = prefix.len() + connector.len();
    let mut runs: Vec<OutputSpan> = Vec::new();
    match classify_detail(detail) {
        Some((offset, len, class)) => {
            let marker_start = detail_start + offset;
            let marker_end = marker_start + len;
            push_neutral(&mut runs, 0, marker_start);
            runs.push(OutputSpan {
                byte_range: (marker_start, marker_end),
                class,
            });
            push_neutral(&mut runs, marker_end, text.len());
            if class == OutputStyleClass::AutomaticIndex {
                let tag_start = text.len();
                text.push_str(AUTO_INDEX_TAG);
                runs.push(OutputSpan {
                    byte_range: (tag_start, text.len()),
                    class,
                });
            }
        }
        None => push_neutral(&mut runs, 0, text.len()),
    }
    OutputLine::styled(text, OutputKind::System, mode, runs)
}

/// A wholly-neutral plan line — used for the display-SQL line,
/// which carries no category keywords. Styled (rather than
/// plain) so it renders in the neutral foreground colour
/// instead of the whole-line `System` styling (ADR-0028 §6).
fn neutral_plan_line(text: String, mode: Mode) -> OutputLine {
    let mut runs = Vec::new();
    push_neutral(&mut runs, 0, text.len());
    OutputLine::styled(text, OutputKind::System, mode, runs)
}

/// Push a `Neutral` span covering `[start, end)`, skipping the
/// empty case.
fn push_neutral(runs: &mut Vec<OutputSpan>, start: usize, end: usize) {
    if end > start {
        runs.push(OutputSpan {
            byte_range: (start, end),
            class: OutputStyleClass::Neutral,
        });
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alignment {
    Left,
    Right,
}

/// Per-column alignment hint for [`render_diagnostic_table`].
#[must_use]
pub const fn numeric_alignment_for(ty: Type) -> Alignment {
    alignment_for(Some(ty))
}

/// Render an arbitrary bordered table from headers + rows +
/// per-column alignments. Used for change-column diagnostic
/// output (ADR-0017 §7) — anything tabular goes through the
/// pretty-table renderer.
///
/// Cell contents are sanitised the same way as
/// [`render_data_table`]: newlines and control characters
/// become visible markers (display-only).
#[must_use]
pub fn render_diagnostic_table(
    headers: &[String],
    rows: &[Vec<String>],
    alignments: &[Alignment],
) -> Vec<String> {
    let body: Vec<Vec<String>> = rows
        .iter()
        .map(|r| r.iter().map(|c| sanitize_cell(c)).collect())
        .collect();
    render_table(headers, &body, alignments)
}

/// Map a user-facing type to its column alignment per
/// ADR-0016 §2. `None` (foreign-attached, no metadata)
/// falls back to Left.
const fn alignment_for(ty: Option<Type>) -> Alignment {
    match ty {
        Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
        Some(Type::Text)
        | Some(Type::Bool)
        | Some(Type::Date)
        | Some(Type::DateTime)
        | Some(Type::Blob)
        | Some(Type::ShortId)
        | None => Alignment::Left,
    }
}

fn type_display(c: &ColumnDescription) -> String {
    c.user_type
        .map_or_else(|| c.sqlite_type.to_lowercase(), |t| t.keyword().to_string())
}

fn constraints_display(c: &ColumnDescription) -> String {
    let mut parts: Vec<String> = Vec::new();
    if c.primary_key {
        parts.push("PK".to_string());
    }
    if c.notnull {
        parts.push("NOT NULL".to_string());
    }
    // ADR-0029: a PK column's implicit uniqueness is already
    // conveyed by `PK`; `unique` is only set for non-PK columns.
    if c.unique {
        parts.push("UNIQUE".to_string());
    }
    if let Some(default) = &c.default {
        parts.push(format!("DEFAULT {default}"));
    }
    if let Some(check) = &c.check {
        parts.push(format!("CHECK ({check})"));
    }
    parts.join(", ")
}

/// Replace newlines / tabs / other control characters with
/// visible display markers per ADR-0016 §3. Display-only;
/// underlying data is untouched.
fn sanitize_cell(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '\n' => out.push('↵'),
            '\t' => out.push('→'),
            c if (c as u32) < 0x20 => out.push('·'),
            other => out.push(other),
        }
    }
    out
}

/// Width in Unicode codepoints. Matches existing
/// width-counting throughout the codebase; deferring true
/// Unicode-Width handling to a later pass.
fn cell_width(s: &str) -> usize {
    s.chars().count()
}

/// Render a single bordered table given header cells, body
/// rows, and per-column alignment. Outer frame +
/// header-underline only.
fn render_table(
    headers: &[String],
    body: &[Vec<String>],
    alignments: &[Alignment],
) -> Vec<String> {
    debug_assert_eq!(headers.len(), alignments.len());

    // Compute column widths: max(header, all body cells).
    // Empty headers + empty body produces an empty table,
    // which we still want to render as a single horizontal
    // line — easier to reason about than a missing one.
    let column_count = headers.len();
    let mut widths: Vec<usize> = headers.iter().map(|s| cell_width(s)).collect();
    for row in body {
        for (i, cell) in row.iter().enumerate() {
            if i < widths.len() {
                let w = cell_width(cell);
                if w > widths[i] {
                    widths[i] = w;
                }
            }
        }
    }

    let mut out: Vec<String> = Vec::with_capacity(body.len() + 3);

    out.push(border_row(&widths, BorderRow::Top));
    out.push(content_row(headers, &widths, alignments));
    out.push(border_row(&widths, BorderRow::HeaderUnderline));
    if column_count == 0 {
        // Nothing more to render; the bottom border closes
        // an empty box. Unusual but well-defined.
    } else {
        for row in body {
            out.push(content_row(row, &widths, alignments));
        }
    }
    out.push(border_row(&widths, BorderRow::Bottom));
    out
}

#[derive(Debug, Clone, Copy)]
enum BorderRow {
    Top,
    HeaderUnderline,
    Bottom,
}

fn border_row(widths: &[usize], kind: BorderRow) -> String {
    let (left, mid, right) = match kind {
        BorderRow::Top => ('┌', '┬', '┐'),
        BorderRow::HeaderUnderline => ('├', '┼', '┤'),
        BorderRow::Bottom => ('└', '┴', '┘'),
    };
    let mut s = String::new();
    s.push(left);
    if widths.is_empty() {
        s.push(right);
        return s;
    }
    for (i, w) in widths.iter().enumerate() {
        if i > 0 {
            s.push(mid);
        }
        // One space of padding on each side of the cell, so
        // a width-w cell occupies w + 2 box columns.
        for _ in 0..(w + 2) {
            s.push('─');
        }
    }
    s.push(right);
    s
}

fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) -> String {
    let mut s = String::new();
    s.push('│');
    for (i, w) in widths.iter().enumerate() {
        let cell = cells.get(i).cloned().unwrap_or_default();
        let align = alignments.get(i).copied().unwrap_or(Alignment::Left);
        s.push(' ');
        let pad_total = w.saturating_sub(cell_width(&cell));
        match align {
            Alignment::Left => {
                s.push_str(&cell);
                for _ in 0..pad_total {
                    s.push(' ');
                }
            }
            Alignment::Right => {
                for _ in 0..pad_total {
                    s.push(' ');
                }
                s.push_str(&cell);
            }
        }
        s.push(' ');
        s.push('│');
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::{ColumnDescription, IndexInfo, RelationshipEnd};
    use crate::dsl::ReferentialAction;
    use insta::assert_snapshot;

    fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription {
        ColumnDescription {
            name: name.to_string(),
            user_type: Some(ty),
            sqlite_type: match ty {
                Type::Int | Type::Serial | Type::Bool => "INTEGER".to_string(),
                Type::Real => "REAL".to_string(),
                Type::Blob => "BLOB".to_string(),
                _ => "TEXT".to_string(),
            },
            notnull,
            primary_key: pk,
            unique: false,
            default: None,
            check: None,
        }
    }

    // --- Width / alignment helpers ----------------------

    #[test]
    fn alignment_for_numeric_types_is_right() {
        for ty in [Type::Int, Type::Real, Type::Decimal, Type::Serial] {
            assert_eq!(alignment_for(Some(ty)), Alignment::Right, "{ty:?}");
        }
    }

    #[test]
    fn alignment_for_other_types_is_left() {
        for ty in [
            Type::Text,
            Type::Bool,
            Type::Date,
            Type::DateTime,
            Type::Blob,
            Type::ShortId,
        ] {
            assert_eq!(alignment_for(Some(ty)), Alignment::Left, "{ty:?}");
        }
    }

    #[test]
    fn alignment_for_unknown_type_is_left() {
        assert_eq!(alignment_for(None), Alignment::Left);
    }

    // --- sanitize_cell ----------------------------------

    #[test]
    fn sanitize_replaces_newline_with_return_arrow() {
        assert_eq!(sanitize_cell("a\nb"), "a↵b");
    }

    #[test]
    fn sanitize_replaces_tab_with_arrow() {
        assert_eq!(sanitize_cell("a\tb"), "a→b");
    }

    #[test]
    fn sanitize_replaces_other_control_chars_with_dot() {
        // \x07 is BEL; should become a middle dot.
        assert_eq!(sanitize_cell("a\x07b"), "a·b");
    }

    #[test]
    fn sanitize_passes_through_normal_text() {
        assert_eq!(sanitize_cell("Alice & Bob"), "Alice & Bob");
    }

    // --- cell_width -------------------------------------

    #[test]
    fn cell_width_counts_codepoints_not_bytes() {
        // 'é' is 2 bytes in UTF-8 but counts as 1 codepoint.
        assert_eq!(cell_width("héllo"), 5);
        assert_eq!("héllo".len(), 6);
    }

    // --- render_data_table snapshots --------------------

    #[test]
    fn render_data_table_basic_shape() {
        let data = DataResult {
            table_name: "Customers".to_string(),
            columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
            column_types: vec![
                Some(Type::Serial),
                Some(Type::Text),
                Some(Type::Text),
            ],
            rows: vec![
                vec![
                    Some("1".to_string()),
                    Some("Alice".to_string()),
                    Some("a@example.io".to_string()),
                ],
                vec![
                    Some("2".to_string()),
                    Some("Bob".to_string()),
                    Some("b@example.io".to_string()),
                ],
            ],
        };
        assert_snapshot!(render_data_table(&data).join("\n"));
    }

    #[test]
    fn render_data_table_empty_rows_shows_no_rows_marker() {
        let data = DataResult {
            table_name: "Customers".to_string(),
            columns: vec!["id".to_string(), "Name".to_string()],
            column_types: vec![Some(Type::Serial), Some(Type::Text)],
            rows: Vec::new(),
        };
        let out = render_data_table(&data).join("\n");
        assert!(out.contains("(no rows)"), "got:\n{out}");
        assert_snapshot!(out);
    }

    #[test]
    fn render_data_table_nulls_render_as_null_marker() {
        let data = DataResult {
            table_name: "T".to_string(),
            columns: vec!["a".to_string(), "b".to_string()],
            column_types: vec![Some(Type::Int), Some(Type::Text)],
            rows: vec![
                vec![Some("1".to_string()), None],
                vec![None, Some("hi".to_string())],
            ],
        };
        let out = render_data_table(&data).join("\n");
        assert!(out.contains("(null)"), "got:\n{out}");
    }

    #[test]
    fn render_data_table_right_aligns_numerics() {
        // A numeric column with mixed-width values should
        // right-align so the digits' ones place stacks.
        // Single-char header keeps column width = max body
        // width = 3 (i.e. "999"), making the assertions
        // explicit.
        let data = DataResult {
            table_name: "T".to_string(),
            columns: vec!["n".to_string()],
            column_types: vec![Some(Type::Int)],
            rows: vec![
                vec![Some("1".to_string())],
                vec![Some("42".to_string())],
                vec![Some("999".to_string())],
            ],
        };
        let out = render_data_table(&data).join("\n");
        // The narrowest value ("1") must have leading
        // padding so its rightmost digit lines up with "9"
        // and "2" in the wider rows.
        assert!(
            out.contains("│   1 │"),
            "expected right-aligned `1`:\n{out}",
        );
        assert!(
            out.contains("│  42 │"),
            "expected right-aligned `42`:\n{out}",
        );
        assert!(
            out.contains("│ 999 │"),
            "expected right-aligned `999`:\n{out}",
        );
    }

    #[test]
    fn render_data_table_left_aligns_text() {
        let data = DataResult {
            table_name: "T".to_string(),
            columns: vec!["Name".to_string()],
            column_types: vec![Some(Type::Text)],
            rows: vec![
                vec![Some("Alice".to_string())],
                vec![Some("Bo".to_string())],
            ],
        };
        let out = render_data_table(&data).join("\n");
        assert!(
            out.contains("│ Alice │"),
            "expected left-aligned `Alice`:\n{out}",
        );
        assert!(
            out.contains("│ Bo    │"),
            "expected left-aligned `Bo` with trailing pad:\n{out}",
        );
    }

    #[test]
    fn render_data_table_handles_newline_in_cell() {
        let data = DataResult {
            table_name: "T".to_string(),
            columns: vec!["note".to_string()],
            column_types: vec![Some(Type::Text)],
            rows: vec![vec![Some("Hello\nWorld".to_string())]],
        };
        let out = render_data_table(&data).join("\n");
        assert!(out.contains("Hello↵World"), "got:\n{out}");
    }

    // --- render_structure snapshots ---------------------

    #[test]
    fn render_structure_basic() {
        let desc = TableDescription {
            name: "Customers".to_string(),
            columns: vec![
                col("id", Type::Serial, true, false),
                col("Name", Type::Text, false, true),
                col("Email", Type::Text, false, false),
            ],
            outbound_relationships: Vec::new(),
            inbound_relationships: Vec::new(),
            indexes: Vec::new(),
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        };
        assert_snapshot!(render_structure(&desc).join("\n"));
    }

    #[test]
    fn render_structure_with_relationships() {
        let desc = TableDescription {
            name: "Customers".to_string(),
            columns: vec![col("id", Type::Serial, true, false)],
            outbound_relationships: Vec::new(),
            inbound_relationships: vec![RelationshipEnd {
                name: "cust_orders".to_string(),
                other_table: "Orders".to_string(),
                other_column: "cust_id".to_string(),
                local_column: "id".to_string(),
                on_delete: ReferentialAction::Cascade,
                on_update: ReferentialAction::NoAction,
            }],
            indexes: Vec::new(),
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        };
        let out = render_structure(&desc).join("\n");
        assert!(
            out.contains("Referenced by:"),
            "expected inbound relationship section:\n{out}",
        );
        assert!(
            out.contains("Orders.cust_id → id"),
            "expected inbound relationship line:\n{out}",
        );
        assert_snapshot!(out);
    }

    #[test]
    fn render_structure_pk_and_notnull_render_in_constraints() {
        let desc = TableDescription {
            name: "T".to_string(),
            columns: vec![
                col("id", Type::Serial, true, false),
                col("name", Type::Text, false, true),
                col("nick", Type::Text, false, false),
            ],
            outbound_relationships: Vec::new(),
            inbound_relationships: Vec::new(),
            indexes: Vec::new(),
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        };
        let out = render_structure(&desc).join("\n");
        // PK appears for id, NOT NULL for name, blank for nick.
        assert!(out.contains("│ id   │ serial │ PK"), "got:\n{out}");
        assert!(out.contains("│ name │ text   │ NOT NULL"), "got:\n{out}");
    }

    #[test]
    fn render_structure_shows_indexes_section() {
        let desc = TableDescription {
            name: "Customers".to_string(),
            columns: vec![
                col("id", Type::Serial, true, false),
                col("Email", Type::Text, false, false),
            ],
            outbound_relationships: Vec::new(),
            inbound_relationships: Vec::new(),
            indexes: vec![IndexInfo {
                name: "idx_email".to_string(),
                columns: vec!["Email".to_string()],
                unique: false,
            }],
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        };
        let out = render_structure(&desc).join("\n");
        assert!(out.contains("Indexes:"), "got:\n{out}");
        assert!(out.contains("idx_email (Email)"), "got:\n{out}");
        // A plain index carries no uniqueness marker.
        assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
    }

    #[test]
    fn render_structure_marks_a_unique_index() {
        // ADR-0035 §4d: a UNIQUE index is marked `[unique]` so a learner
        // can tell it from a performance-only index.
        let desc = TableDescription {
            name: "Customers".to_string(),
            columns: vec![
                col("id", Type::Serial, true, false),
                col("Email", Type::Text, false, false),
            ],
            outbound_relationships: Vec::new(),
            inbound_relationships: Vec::new(),
            indexes: vec![IndexInfo {
                name: "uidx_email".to_string(),
                columns: vec!["Email".to_string()],
                unique: true,
            }],
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        };
        let out = render_structure(&desc).join("\n");
        assert!(out.contains("uidx_email (Email) [unique]"), "got:\n{out}");
    }

    #[test]
    fn render_structure_shows_table_level_constraints() {
        // ADR-0035 §4i (b): composite UNIQUE and table-level CHECK
        // (named + unnamed) render in a "Table constraints:" section,
        // distinct from the per-column "Constraints" column.
        let desc = TableDescription {
            name: "T".to_string(),
            columns: vec![
                col("a", Type::Int, true, false),
                col("b", Type::Int, false, false),
            ],
            outbound_relationships: Vec::new(),
            inbound_relationships: Vec::new(),
            indexes: Vec::new(),
            unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
            check_constraints: vec![
                crate::persistence::TableCheck { name: None, expr: "a < b".to_string() },
                crate::persistence::TableCheck {
                    name: Some("a_lt_b".to_string()),
                    expr: "a <> b".to_string(),
                },
            ],
        };
        let out = render_structure(&desc).join("\n");
        assert!(out.contains("Table constraints:"), "got:\n{out}");
        assert!(out.contains("unique (a, b)"), "got:\n{out}");
        // The composite UNIQUE shows its derived, addressable name
        // (ADR-0035 Amendment 1) so the user can `drop constraint <name>`.
        assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}");
        assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
        assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
    }

    #[test]
    fn render_structure_omits_indexes_section_when_none() {
        let desc = TableDescription {
            name: "T".to_string(),
            columns: vec![col("id", Type::Serial, true, false)],
            outbound_relationships: Vec::new(),
            inbound_relationships: Vec::new(),
            indexes: Vec::new(),
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        };
        let out = render_structure(&desc).join("\n");
        assert!(!out.contains("Indexes:"), "got:\n{out}");
    }

    // --- render_explain_plan (ADR-0028 §3) --------------

    #[test]
    fn render_explain_plan_puts_display_sql_first() {
        let plan = QueryPlan {
            display_sql: "SELECT \"id\" FROM \"T\"".to_string(),
            rows: vec![ExplainRow {
                id: 2,
                parent: 0,
                detail: "SCAN T".to_string(),
            }],
        };
        let lines = render_explain_plan(&plan, Mode::Simple);
        assert_eq!(lines[0].text, "SELECT \"id\" FROM \"T\"");
        assert!(lines[1].text.contains("SCAN T"), "got {:?}", lines[1].text);
        assert!(lines[1].text.contains('└'), "last node uses └─");
    }

    #[test]
    fn render_explain_plan_nests_children_under_parents() {
        let plan = QueryPlan {
            display_sql: "SELECT 1".to_string(),
            rows: vec![
                ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
                ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
                ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
            ],
        };
        let lines = render_explain_plan(&plan, Mode::Simple);
        // display SQL + 3 plan nodes.
        assert_eq!(lines.len(), 4);
        assert!(lines[1].text.contains("root"));
        assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
        assert!(lines[3].text.contains("└─ child-b"), "got {:?}", lines[3].text);
        // The single root uses `└─`; its children are indented
        // by three spaces (no `│` spine, the root being last).
        assert!(lines[1].text.starts_with("└─ root"));
        assert!(lines[2].text.starts_with("   ├─ child-a"));
    }

    /// The semantic class of the styled run covering the first
    /// occurrence of `needle` in `line`'s text.
    fn span_class_for(line: &OutputLine, needle: &str) -> OutputStyleClass {
        let runs = line.styled_runs.as_ref().expect("line should be styled");
        let at = line.text.find(needle).expect("needle present in text");
        runs.iter()
            .find(|s| s.byte_range.0 <= at && at < s.byte_range.1)
            .map(|s| s.class)
            .expect("a styled run covers the needle")
    }

    fn one_node_plan(detail: &str) -> QueryPlan {
        QueryPlan {
            display_sql: "SELECT 1".to_string(),
            rows: vec![ExplainRow {
                id: 1,
                parent: 0,
                detail: detail.to_string(),
            }],
        }
    }

    #[test]
    fn render_explain_plan_colours_a_full_scan_expensive() {
        let plan = one_node_plan("SCAN Customers");
        let lines = render_explain_plan(&plan, Mode::Simple);
        assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive);
        // The table name stays neutral (ADR-0028 §6).
        assert_eq!(
            span_class_for(&lines[1], "Customers"),
            OutputStyleClass::Neutral,
        );
    }

    #[test]
    fn render_explain_plan_colours_an_index_search_efficient() {
        let plan = one_node_plan("SEARCH Customers USING INDEX Customers_Email_idx (Email=?)");
        let lines = render_explain_plan(&plan, Mode::Simple);
        assert_eq!(
            span_class_for(&lines[1], "USING INDEX"),
            OutputStyleClass::Efficient,
        );
        // The index name and the connector stay neutral.
        assert_eq!(
            span_class_for(&lines[1], "Customers_Email_idx"),
            OutputStyleClass::Neutral,
        );
        assert_eq!(span_class_for(&lines[1], "└─"), OutputStyleClass::Neutral);
    }

    #[test]
    fn render_explain_plan_flags_an_automatic_index() {
        let plan =
            one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
        let lines = render_explain_plan(&plan, Mode::Simple);
        assert_eq!(
            span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"),
            OutputStyleClass::AutomaticIndex,
        );
        // ADR-0028 §6: the distinct "add an index" advice tag.
        assert!(
            lines[1].text.ends_with("← add an index?"),
            "got {:?}",
            lines[1].text,
        );
        assert_eq!(
            span_class_for(&lines[1], "add an index"),
            OutputStyleClass::AutomaticIndex,
        );
    }

    #[test]
    fn render_explain_plan_temp_btree_is_expensive() {
        let plan = one_node_plan("USE TEMP B-TREE FOR ORDER BY");
        let lines = render_explain_plan(&plan, Mode::Simple);
        assert_eq!(
            span_class_for(&lines[1], "USE TEMP B-TREE"),
            OutputStyleClass::Expensive,
        );
    }

    #[test]
    fn render_explain_plan_covering_index_scan_classifies_on_the_index() {
        // A `SCAN … USING INDEX …` is a covering-index scan —
        // it must read as efficient, not as a full scan.
        let plan = one_node_plan("SCAN Customers USING COVERING INDEX idx");
        let lines = render_explain_plan(&plan, Mode::Simple);
        assert_eq!(
            span_class_for(&lines[1], "USING COVERING INDEX"),
            OutputStyleClass::Efficient,
        );
        assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Neutral);
    }

    #[test]
    fn render_explain_plan_unrecognised_detail_is_neutral() {
        let plan = one_node_plan("CO-ROUTINE 0x1");
        let lines = render_explain_plan(&plan, Mode::Simple);
        let runs = lines[1].styled_runs.as_ref().unwrap();
        assert!(
            runs.iter().all(|s| s.class == OutputStyleClass::Neutral),
            "an unrecognised detail must render wholly neutral: {runs:?}",
        );
    }

    #[test]
    fn render_explain_plan_display_sql_line_is_neutral() {
        let plan = one_node_plan("SCAN T");
        let lines = render_explain_plan(&plan, Mode::Simple);
        let runs = lines[0].styled_runs.as_ref().unwrap();
        assert!(runs.iter().all(|s| s.class == OutputStyleClass::Neutral));
    }

    #[test]
    fn render_explain_plan_survives_a_self_referential_node() {
        // A malformed plan node that is both a root (`parent ==
        // 0`) and its own parent (`id == 0`) must not loop.
        let plan = QueryPlan {
            display_sql: String::new(),
            rows: vec![ExplainRow {
                id: 0,
                parent: 0,
                detail: "self-rooted".to_string(),
            }],
        };
        let lines = render_explain_plan(&plan, Mode::Simple);
        // display-SQL line + the node, drawn exactly once.
        assert_eq!(lines.len(), 2);
    }

    #[test]
    fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
        let mut desc = TableDescription {
            name: "T".to_string(),
            columns: vec![ColumnDescription {
                name: "x".to_string(),
                user_type: None,
                sqlite_type: "INTEGER".to_string(),
                notnull: false,
                primary_key: false,
                unique: false,
                default: None,
                check: None,
            }],
            outbound_relationships: Vec::new(),
            inbound_relationships: Vec::new(),
            indexes: Vec::new(),
            unique_constraints: Vec::new(),
            check_constraints: Vec::new(),
        };
        let out = render_structure(&desc).join("\n");
        // The lowercase form of the SQLite type should appear.
        assert!(out.contains("integer"), "got:\n{out}");
        // And the name column should still align correctly with no user_type.
        desc.columns[0].user_type = Some(Type::Int);
        let with_type = render_structure(&desc).join("\n");
        assert!(with_type.contains("int"), "got:\n{with_type}");
    }
}
