//! UI state and pure key-handling for the table-based authoring TUI.
//!
//! Key handlers are pure functions over `(UiState, &mut EditModel, KeyEvent)` so
//! they are unit-testable without a terminal. The cursor is two-level:
//! `cell: None` selects a whole table row; `cell: Some(c)` edits a cell.

use crate::tui::model::{EditModel, EditSchema};
use crate::tui::rows::{BodyLoc, CellKind, Expand, Field, RowKind, Section, TableRow, flatten};
use apic_core::edit::{EditAction, apply};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use std::path::Path;

const HINT: &str = "↑↓ select · Enter edit/open · ←→ cell · a add · d delete · Esc back · Ctrl-S save · q quit · ? help";

#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) enum Mode {
    #[default]
    Normal,
    Insert(String),
    Example,
    Help,
    ConfirmQuit,
    ConfirmDelete(Field),
}

#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Action {
    None,
    OpenExample(Field, String),
    Save,
    Quit,
}

pub(crate) struct UiState {
    pub sections: Vec<Section>,
    pub sec: usize,
    pub row: usize,
    pub cell: Option<usize>,
    pub mode: Mode,
    pub dirty: bool,
    pub status: String,
    pub expanded: Option<Expand>,

    /// Baseline snapshot (last loaded/saved model) against which `dirty` is
    /// computed, so the unsaved indicator reflects real differences.
    pub original: EditModel,
}

impl UiState {
    pub(super) fn new(model: &EditModel) -> Self {
        let sections = flatten(model, None);
        let mut s = UiState {
            sections,
            sec: 0,
            row: 0,
            cell: None,
            mode: Mode::Normal,
            dirty: false,
            status: HINT.to_string(),
            expanded: None,
            original: model.clone(),
        };
        s.snap_to_first_row();
        s
    }

    /// Rebuilds sections after a mutation, clamping the cursor; drops cell focus
    /// if it no longer addresses a valid cell.
    pub(super) fn refresh(&mut self, model: &EditModel) {
        self.sections = flatten(model, self.expanded);
        if self.sec >= self.sections.len() {
            self.sec = self.sections.len().saturating_sub(1);
        }
        let nrows = self
            .sections
            .get(self.sec)
            .map(|s| s.rows.len())
            .unwrap_or(0);
        if self.row >= nrows {
            self.row = nrows.saturating_sub(1);
        }
        if let Some(c) = self.cell {
            let ncells = self.current_row().map(|r| r.cells.len()).unwrap_or(0);
            if c >= ncells {
                self.cell = None;
            }
        }
        self.dirty = model != &self.original;
    }

    fn snap_to_first_row(&mut self) {
        for (si, s) in self.sections.iter().enumerate() {
            if !s.rows.is_empty() {
                self.sec = si;
                self.row = 0;
                return;
            }
        }
    }

    pub(super) fn current_row(&self) -> Option<&TableRow> {
        self.sections.get(self.sec)?.rows.get(self.row)
    }

    /// The field of the focused cell (cell-edit mode), if any.
    fn focused_field(&self) -> Option<Field> {
        let c = self.cell?;
        self.current_row()?
            .cells
            .get(c)
            .map(|cell| cell.field.clone())
    }

    pub(super) fn focused_field_pub(&self) -> Option<Field> {
        self.focused_field()
    }

    /// Indices of editable (non-Label) cells in the current row.
    fn editable_cells(&self) -> Vec<usize> {
        self.current_row()
            .map(|r| {
                r.cells
                    .iter()
                    .enumerate()
                    .filter(|(_, c)| c.kind != CellKind::Label)
                    .map(|(i, _)| i)
                    .collect()
            })
            .unwrap_or_default()
    }

    /// Moves row selection by `dir` across section boundaries (cell reset).
    fn move_row(&mut self, dir: isize) {
        let coords: Vec<(usize, usize)> = self
            .sections
            .iter()
            .enumerate()
            .flat_map(|(si, s)| (0..s.rows.len()).map(move |ri| (si, ri)))
            .collect();
        if coords.is_empty() {
            return;
        }
        let pos = coords
            .iter()
            .position(|&(si, ri)| si == self.sec && ri == self.row)
            .unwrap_or(0);
        let np = (pos as isize + dir).clamp(0, coords.len() as isize - 1) as usize;
        let (s, r) = coords[np];
        self.sec = s;
        self.row = r;
        self.cell = None;
    }

    /// Moves the focused cell by `dir` among editable cells.
    fn move_cell(&mut self, dir: isize) {
        let edit = self.editable_cells();
        if edit.is_empty() {
            return;
        }
        let cur = self.cell.unwrap_or(edit[0]);
        let pos = edit.iter().position(|&i| i == cur).unwrap_or(0);
        let np = (pos as isize + dir).clamp(0, edit.len() as isize - 1) as usize;
        self.cell = Some(edit[np]);
    }
}

/// The field used by `d` (delete) on the focused row: the first editable cell,
/// else the first cell.
fn delete_field(state: &UiState) -> Option<Field> {
    let row = state.current_row()?;
    row.cells
        .iter()
        .find(|c| c.kind != CellKind::Label)
        .or_else(|| row.cells.first())
        .map(|c| c.field.clone())
}

/// Whether `delete_row` would actually remove a row for this field — matching
/// exactly the variants it handles (path/query/var/header/response/schema).
fn is_deletable(field: &Field) -> bool {
    matches!(
        field,
        Field::PathSeg(_)
            | Field::QueryName(_)
            | Field::QueryType(_)
            | Field::QueryDesc(_)
            | Field::QueryRequired(_)
            | Field::VarName(_)
            | Field::VarType(_)
            | Field::VarDesc(_)
            | Field::VarRequired(_)
            | Field::HeaderName(_)
            | Field::HeaderValue(_)
            | Field::ResponseCode(_)
            | Field::ResponseDesc(_)
            | Field::SchemaName(_, _)
            | Field::SchemaType(_, _)
            | Field::SchemaDesc(_, _)
            | Field::SchemaRequired(_, _)
            | Field::SchemaAccept(_, _)
    )
}

/// Handles one key in Normal mode (row-select or cell-edit per `state.cell`).
pub(crate) fn handle_normal(state: &mut UiState, model: &mut EditModel, key: KeyEvent) -> Action {
    if (key.code, key.modifiers) == (KeyCode::Char('s'), KeyModifiers::CONTROL) {
        return Action::Save;
    }
    if state.cell.is_some() {
        return handle_cell(state, model, key);
    }
    match (key.code, key.modifiers) {
        // Esc first collapses any expanded region, before the quit flow.
        (KeyCode::Esc, _) if state.expanded.is_some() => {
            state.expanded = None;
            state.refresh(model);
            Action::None
        }
        (KeyCode::Char('q'), _) | (KeyCode::Esc, _) => {
            if state.dirty {
                state.mode = Mode::ConfirmQuit;
                Action::None
            } else {
                Action::Quit
            }
        }
        (KeyCode::Char('?'), _) => {
            state.mode = Mode::Help;
            Action::None
        }
        (KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
            state.move_row(1);
            Action::None
        }
        (KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
            state.move_row(-1);
            Action::None
        }
        (KeyCode::Enter, _) => begin_row(state, model),
        (KeyCode::Char('a'), _) => {
            append_here(state, model);
            Action::None
        }
        (KeyCode::Char('g'), _) => {
            generate_example_here(state, model);
            Action::None
        }
        (KeyCode::Char('G'), _) => {
            infer_schema_here(state, model);
            Action::None
        }
        (KeyCode::Char('e'), _) => edit_example_here(state),
        (KeyCode::Char('d'), _) => {
            if let Some(f) = delete_field(state)
                && is_deletable(&f)
            {
                state.mode = Mode::ConfirmDelete(f);
            }
            Action::None
        }
        _ => Action::None,
    }
}

/// Generates an example JSON for the request/response body the cursor is in,
/// from that body's schema fields, filling its example buffer.
fn generate_example_here(state: &mut UiState, model: &mut EditModel) {
    let loc = match state.sections.get(state.sec).and_then(|s| s.expand) {
        Some(Expand::Request) => BodyLoc::Request,
        Some(Expand::Response(i)) => BodyLoc::Response(i),
        _ => return,
    };
    if apply(model, &EditAction::GenerateExample { loc }) {
        state.dirty = true;
        state.refresh(model);
    }
}

/// Fills the schema of the request/response body the cursor is in from that
/// body's example buffer (inverse of `generate_example_here`). On a parse/shape
/// error the message is shown on the status line; on success the rows are
/// refreshed and no status message is shown.
fn infer_schema_here(state: &mut UiState, model: &mut EditModel) {
    let loc = match state.sections.get(state.sec).and_then(|s| s.expand) {
        Some(Expand::Request) => BodyLoc::Request,
        Some(Expand::Response(i)) => BodyLoc::Response(i),
        _ => return,
    };
    if apply(model, &EditAction::InferSchema { loc }) {
        state.dirty = true;
        state.refresh(model);
    } else if let Some((_, msg)) = &model.last_error {
        state.status = format!("schema error: {msg}");
    }
}

/// Opens the JSON example editor for the request/response body the cursor is in,
/// so an example can be written even when it (and the schema) are empty. Returns
/// `Action::None` when the cursor is not inside a body.
fn edit_example_here(state: &UiState) -> Action {
    match state.sections.get(state.sec).and_then(|s| s.expand) {
        Some(Expand::Request) => {
            Action::OpenExample(Field::BodyExample(BodyLoc::Request), String::new())
        }
        Some(Expand::Response(i)) => {
            Action::OpenExample(Field::BodyExample(BodyLoc::Response(i)), String::new())
        }
        _ => Action::None,
    }
}

/// Appends a row near the focused schema field — a child under an object field,
/// a sibling otherwise — falling back to the current section's `add` target.
/// After the add, focuses the new row's name cell and enters insert mode so the
/// user can start typing immediately (except `RequestToggle`, which has no name).
fn append_here(state: &mut UiState, model: &mut EditModel) {
    let target = if let Some((loc, path, is_object)) = focused_schema_target(state) {
        if is_object {
            Field::SchemaAdd(loc, path)
        } else {
            let mut parent = path;
            parent.pop();
            Field::SchemaAdd(loc, parent)
        }
    } else if let Some(field) = state.sections.get(state.sec).and_then(|s| s.add.clone()) {
        field
    } else {
        return;
    };
    add_row(state, model, &target);
    if target == Field::ResponseAdd {
        // The new response starts collapsed; expand it so its code row exists,
        // then drop the cursor into that code cell in insert mode.
        if let Some(idx) = model.responses.len().checked_sub(1) {
            state.expanded = Some(Expand::Response(idx));
            state.refresh(model);
            focus_and_insert(state, &Field::ResponseCode(idx));
        }
        return;
    }
    if let Some(nf) = new_name_field(model, &target) {
        focus_and_insert(state, &nf);
    }
}

/// The "name" field of the just-added entity for `target`, used to auto-focus
/// and enter insert mode after `add_row`. Returns `None` for `RequestToggle`
/// (no name) or when the add did not produce a row.
fn new_name_field(model: &EditModel, target: &Field) -> Option<Field> {
    match target {
        Field::QueryAdd => model.url.query.len().checked_sub(1).map(Field::QueryName),
        Field::VarAdd => model.url.variable.len().checked_sub(1).map(Field::VarName),
        Field::HeaderAdd => model.headers.len().checked_sub(1).map(Field::HeaderName),
        Field::PathAdd => model.url.path.len().checked_sub(1).map(Field::PathSeg),
        Field::ResponseAdd => model
            .responses
            .len()
            .checked_sub(1)
            .map(Field::ResponseCode),
        Field::SchemaAdd(loc, path) => {
            let len = schema_children_len(model, loc, path)?;
            let new_index = len.checked_sub(1)?;
            let mut p = path.clone();
            p.push(new_index);
            Some(Field::SchemaName(loc.clone(), p))
        }
        _ => None,
    }
}

/// Number of children at `path` for `loc` (the vector a `SchemaAdd` appends to),
/// after the add. `None` if the path does not resolve.
fn schema_children_len(model: &EditModel, loc: &BodyLoc, path: &[usize]) -> Option<usize> {
    let root = match loc {
        BodyLoc::Request => model.request.as_ref()?.schema.as_slice(),
        BodyLoc::Response(r) => model.responses.get(*r)?.schema.as_slice(),
    };
    schema_children_len_at(root, path)
}

/// Walks `fields` along `path`, returning the length of the child vector to
/// append into ([] = the top-level `fields`). Mirrors `model::schema_node_mut`.
fn schema_children_len_at(fields: &[EditSchema], path: &[usize]) -> Option<usize> {
    match path.split_first() {
        None => Some(fields.len()),
        Some((&first, rest)) => schema_children_len_at(&fields.get(first)?.properties, rest),
    }
}

/// Focuses the row+cell whose field equals `name_field` and enters insert mode
/// seeded with that cell's current value (empty for a fresh row). No-op if not
/// found.
fn focus_and_insert(state: &mut UiState, name_field: &Field) {
    for (si, sec) in state.sections.iter().enumerate() {
        for (ri, row) in sec.rows.iter().enumerate() {
            if let Some(ci) = row.cells.iter().position(|c| &c.field == name_field) {
                state.sec = si;
                state.row = ri;
                state.cell = Some(ci);
                state.mode = Mode::Insert(row.cells[ci].value.clone());
                return;
            }
        }
    }
}

/// If the focused row is a schema field, returns its (loc, path) and whether its
/// declared type is an object (so `a` adds a child rather than a sibling).
fn focused_schema_target(state: &UiState) -> Option<(BodyLoc, Vec<usize>, bool)> {
    let row = state.current_row()?;
    let (loc, path) = row.cells.iter().find_map(|c| match &c.field {
        Field::SchemaName(l, p)
        | Field::SchemaType(l, p)
        | Field::SchemaRequired(l, p)
        | Field::SchemaAccept(l, p)
        | Field::SchemaDesc(l, p) => Some((l.clone(), p.clone())),
        _ => None,
    })?;
    let dtype = row
        .cells
        .iter()
        .find_map(|c| matches!(&c.field, Field::SchemaType(_, _)).then(|| c.value.clone()))
        .unwrap_or_default();
    let is_object = apic_core::json::parse_type(&dtype).0 == "object";
    Some((loc, path, is_object))
}

/// Keys while a cell is focused (cell-edit mode).
fn handle_cell(state: &mut UiState, model: &mut EditModel, key: KeyEvent) -> Action {
    match key.code {
        KeyCode::Esc => {
            state.cell = None;
            Action::None
        }
        KeyCode::Left => {
            state.move_cell(-1);
            Action::None
        }
        KeyCode::Right => {
            state.move_cell(1);
            Action::None
        }
        KeyCode::Char('h') => {
            state.move_cell(-1);
            Action::None
        }
        KeyCode::Char('l') => {
            state.move_cell(1);
            Action::None
        }
        KeyCode::Char(' ') => {
            if let (Some(c), Some(field)) = (state.cell, state.focused_field()) {
                let is_bool = state
                    .current_row()
                    .and_then(|r| r.cells.get(c))
                    .map(|cell| cell.kind == CellKind::Bool)
                    .unwrap_or(false);
                if is_bool {
                    apply(model, &EditAction::ToggleBool { field });
                    state.dirty = true;
                    state.refresh(model);
                }
            }
            Action::None
        }
        KeyCode::Char('i') => {
            if let Some(cell) = state
                .cell
                .and_then(|c| state.current_row().and_then(|r| r.cells.get(c)))
                && cell.kind == CellKind::Text
            {
                state.mode = Mode::Insert(cell.value.clone());
            }
            Action::None
        }
        KeyCode::Enter => begin_cell_edit(state, model),
        _ => Action::None,
    }
}

/// Enter on a selected row (row-select mode). For editable rows this selects
/// the first cell and begins editing it immediately (no second keypress).
fn begin_row(state: &mut UiState, model: &mut EditModel) -> Action {
    let Some(row) = state.current_row().cloned() else {
        return Action::None;
    };
    match row.kind {
        RowKind::UrlLine | RowKind::Title => {
            // Title rows on table sections (VARIABLE/QUERY/HEADERS/empty RESPONSE)
            // have no expand target — Enter does nothing there.
            let Some(tgt) = state.sections[state.sec].expand else {
                return Action::None;
            };
            state.expanded = if state.expanded == Some(tgt) {
                None
            } else {
                Some(tgt)
            };
            state.cell = None;
            state.refresh(model);
            Action::None
        }
        RowKind::Example => Action::OpenExample(row.cells[0].field.clone(), String::new()),
        RowKind::Name | RowKind::Desc | RowKind::Field => {
            // Select the first editable cell and drop straight into editing it
            // (insert for text, toggle/cycle for bool/enum) so the user can
            // start typing without a second Enter.
            if let Some(&first) = state.editable_cells().first() {
                state.cell = Some(first);
                return begin_cell_edit(state, model);
            }
            Action::None
        }
    }
}

/// Enter on a focused cell (cell-edit mode): dispatch by cell kind.
fn begin_cell_edit(state: &mut UiState, model: &mut EditModel) -> Action {
    let Some(c) = state.cell else {
        return Action::None;
    };
    let Some(cell) = state.current_row().and_then(|r| r.cells.get(c)).cloned() else {
        return Action::None;
    };
    match cell.kind {
        CellKind::Text => {
            state.mode = Mode::Insert(cell.value.clone());
            Action::None
        }
        CellKind::Enum => {
            match &cell.field {
                Field::BodyDtype(loc) => toggle_body_type(state, model, loc),
                _ => cycle_method(state, model, true),
            }
            Action::None
        }
        CellKind::Bool => {
            apply(
                model,
                &EditAction::ToggleBool {
                    field: cell.field.clone(),
                },
            );
            state.dirty = true;
            state.refresh(model);
            Action::None
        }
        CellKind::Label => Action::None,
    }
}

/// Cycles the method enum forward/back (the only enum field today).
fn cycle_method(state: &mut UiState, model: &mut EditModel, forward: bool) {
    apply(model, &EditAction::CycleMethod { forward });
    state.dirty = true;
    state.refresh(model);
}

/// Toggles a request/response body type between `object` and `object[]`.
fn toggle_body_type(state: &mut UiState, model: &mut EditModel, loc: &BodyLoc) {
    apply(model, &EditAction::ToggleBodyType { loc: loc.clone() });
    state.dirty = true;
    state.refresh(model);
}

fn add_row(state: &mut UiState, model: &mut EditModel, field: &Field) {
    if apply(
        model,
        &EditAction::Add {
            field: field.clone(),
        },
    ) {
        state.dirty = true;
        state.cell = None;
        state.refresh(model);
    }
}

fn delete_row(state: &mut UiState, model: &mut EditModel, field: &Field) {
    if apply(
        model,
        &EditAction::Delete {
            field: field.clone(),
        },
    ) {
        state.dirty = true;
        state.cell = None;
        state.refresh(model);
    }
}

/// Handles a key while editing a single-line field.
pub(crate) fn handle_insert(state: &mut UiState, model: &mut EditModel, key: KeyEvent) -> Action {
    let Mode::Insert(buf) = &mut state.mode else {
        return Action::None;
    };
    match key.code {
        KeyCode::Char(c) => {
            buf.push(c);
            Action::None
        }
        KeyCode::Backspace => {
            buf.pop();
            Action::None
        }
        KeyCode::Enter => {
            let value = buf.clone();
            let field = state.focused_field_pub();
            if let Some(f) = &field {
                apply(
                    model,
                    &EditAction::SetText {
                        field: f.clone(),
                        value,
                    },
                );
                state.dirty = true;
            }
            state.mode = Mode::Normal;
            // Name and Description are single-cell rows: commit returns to row
            // focus instead of parking the cursor in a pointless cell focus.
            if matches!(field, Some(Field::Name | Field::Description)) {
                state.cell = None;
            }
            state.refresh(model);
            Action::None
        }
        // Tab / Shift-Tab commit the current value, then jump to the next /
        // previous cell — and keep typing there when it is a text cell, for a
        // fast tab-through-the-row entry flow.
        KeyCode::Tab | KeyCode::BackTab => {
            let value = buf.clone();
            let dir = if key.code == KeyCode::BackTab { -1 } else { 1 };
            if let Some(field) = state.focused_field_pub() {
                apply(model, &EditAction::SetText { field, value });
                state.dirty = true;
            }
            state.mode = Mode::Normal;
            state.refresh(model);
            state.move_cell(dir);
            if let Some(c) = state.cell
                && let Some(cell) = state.current_row().and_then(|r| r.cells.get(c))
                && cell.kind == CellKind::Text
            {
                state.mode = Mode::Insert(cell.value.clone());
            }
            Action::None
        }
        KeyCode::Esc => {
            // Name and Description never rest in cell focus: Esc out of editing
            // returns to row focus, same as committing with Enter.
            if matches!(
                state.focused_field_pub(),
                Some(Field::Name | Field::Description)
            ) {
                state.cell = None;
            }
            state.mode = Mode::Normal;
            Action::None
        }
        _ => Action::None,
    }
}

/// Saves the model to `path`, updating dirty flag and status line.
pub(crate) fn apply_save(state: &mut UiState, model: &EditModel, path: &Path) {
    match model.save(path) {
        Ok(()) => {
            state.original = model.clone();
            state.dirty = false;
            state.status = format!("saved {}", path.display());
        }
        Err(err) => {
            state.status = format!("save error: {err}");
        }
    }
}

/// Handles keys while the quit confirmation is showing. Returns the action.
pub(crate) fn handle_confirm_quit(state: &mut UiState, key: KeyEvent) -> Action {
    match key.code {
        KeyCode::Char('y') => Action::Save, // event loop saves, then quits
        KeyCode::Char('n') => Action::Quit,
        KeyCode::Esc => {
            state.mode = Mode::Normal;
            state.status = "Ctrl-S save · q quit · ? help".into();
            Action::None
        }
        _ => Action::None,
    }
}

/// Handles keys while the delete confirmation is showing.
pub(crate) fn handle_confirm_delete(
    state: &mut UiState,
    model: &mut EditModel,
    key: KeyEvent,
) -> Action {
    match key.code {
        KeyCode::Char('y') => {
            if let Mode::ConfirmDelete(f) = state.mode.clone() {
                delete_row(state, model, &f);
            }
            state.mode = Mode::Normal;
            Action::None
        }
        KeyCode::Char('n') | KeyCode::Esc => {
            state.mode = Mode::Normal;
            Action::None
        }
        _ => Action::None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use apic_core::json::{json_get, method_str};

    fn model() -> EditModel {
        let c = json_get(
            r#"{ "name":"t","description":"d","method":"GET",
                 "url":{"protocol":"https","host":"h","path":["x"],
                        "query":[{"name":"page","type":"1","description":"d","required":false}]},
                 "headers":[{"name":"A","value":"B"}],
                 "responses":[{"code":200,"description":"ok","schema":[]}] }"#,
            None,
        )
        .unwrap();
        EditModel::from_contract(c)
    }
    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }
    fn goto(s: &mut UiState, pred: impl Fn(&Field) -> bool) {
        for (si, sec) in s.sections.iter().enumerate() {
            for (ri, row) in sec.rows.iter().enumerate() {
                if row.cells.iter().any(|c| pred(&c.field)) {
                    s.sec = si;
                    s.row = ri;
                    s.cell = None;
                    return;
                }
            }
        }
        panic!("no matching row");
    }

    #[test]
    fn enter_on_url_expands_then_esc_collapses() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::Method)); // url line carries Method
        handle_normal(&mut s, &mut m, key(KeyCode::Enter));
        assert_eq!(s.expanded, Some(Expand::Url));
        handle_normal(&mut s, &mut m, key(KeyCode::Esc));
        assert_eq!(s.expanded, None);
    }

    #[test]
    fn enter_on_name_row_goes_straight_to_insert() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::Name));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // -> insert directly
        assert!(s.cell.is_some(), "first cell is selected");
        assert!(matches!(s.mode, Mode::Insert(_)), "no second Enter needed");
    }

    #[test]
    fn name_commit_returns_to_row_focus() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::Name));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert ("t")
        handle_insert(&mut s, &mut m, key(KeyCode::Char('x')));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter)); // commit "tx"
        assert_eq!(m.name, "tx");
        assert!(s.cell.is_none(), "Name drops back to row focus on commit");
    }

    #[test]
    fn name_esc_returns_to_row_focus() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::Name));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert
        handle_insert(&mut s, &mut m, key(KeyCode::Esc)); // cancel editing
        assert_eq!(s.mode, Mode::Normal);
        assert!(s.cell.is_none(), "Name drops back to row focus on Esc too");
    }

    #[test]
    fn field_row_esc_keeps_cell_focus() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::QueryName(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert
        handle_insert(&mut s, &mut m, key(KeyCode::Esc)); // cancel editing
        assert!(
            s.cell.is_some(),
            "multi-cell row falls back to cell focus on Esc"
        );
    }

    #[test]
    fn field_row_commit_keeps_cell_focus() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::QueryName(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert directly
        assert!(matches!(s.mode, Mode::Insert(_)));
        handle_insert(&mut s, &mut m, key(KeyCode::Char('X')));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter)); // commit
        assert!(
            s.cell.is_some(),
            "multi-cell row stays in cell focus after commit"
        );
    }

    #[test]
    fn i_enters_insert_on_text_cell() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::QueryName(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert directly
        handle_insert(&mut s, &mut m, key(KeyCode::Esc)); // -> cell focus
        assert!(s.cell.is_some());
        assert_eq!(s.mode, Mode::Normal);
        handle_normal(&mut s, &mut m, key(KeyCode::Char('i'))); // i -> insert again
        assert!(matches!(s.mode, Mode::Insert(_)));
    }

    #[test]
    fn response_title_expands_and_code_is_editable() {
        let mut m = model();
        let mut s = UiState::new(&m);
        // find the RESPONSE title row (RowKind::Title in a Response section)
        let (si, ri) = s
            .sections
            .iter()
            .enumerate()
            .find_map(|(si, sec)| {
                sec.rows
                    .iter()
                    .position(|r| r.kind == RowKind::Title)
                    .filter(|_| matches!(sec.expand, Some(Expand::Response(_))))
                    .map(|ri| (si, ri))
            })
            .unwrap();
        s.sec = si;
        s.row = ri;
        s.cell = None;
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // expand
        assert!(matches!(s.expanded, Some(Expand::Response(_))));
        // a code row now exists and is editable
        s.refresh(&m);
        goto(&mut s, |f| matches!(f, Field::ResponseCode(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert (prefilled "200")
        handle_insert(&mut s, &mut m, key(KeyCode::Backspace));
        handle_insert(&mut s, &mut m, key(KeyCode::Char('1')));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter));
        assert_eq!(m.responses[0].code, "201");
        // first esc exits cell-edit, second esc collapses
        handle_normal(&mut s, &mut m, key(KeyCode::Esc));
        handle_normal(&mut s, &mut m, key(KeyCode::Esc));
        assert_eq!(s.expanded, None);
    }

    #[test]
    fn a_appends_to_current_section() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::HeaderName(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
        assert_eq!(m.headers.len(), 2);
    }

    #[test]
    fn a_auto_enters_insert_on_new_name() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::HeaderName(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
        assert_eq!(m.headers.len(), 2);
        assert!(matches!(s.mode, Mode::Insert(_)));
        assert!(matches!(s.focused_field_pub(), Some(Field::HeaderName(1))));
        // typing then Enter commits to the new header name and keeps cell focus
        handle_insert(&mut s, &mut m, key(KeyCode::Char('X')));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter));
        assert_eq!(m.headers[1].name, "X");
        assert!(s.cell.is_some());
    }

    #[test]
    fn d_deletes_focused_row() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::QueryName(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Char('d')));
        handle_confirm_delete(&mut s, &mut m, key(KeyCode::Char('y')));
        assert_eq!(m.url.query.len(), 0);
    }

    #[test]
    fn delete_requires_confirmation() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::HeaderName(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Char('d')));
        assert!(matches!(s.mode, Mode::ConfirmDelete(_)));
        assert_eq!(m.headers.len(), 1);
        handle_confirm_delete(&mut s, &mut m, key(KeyCode::Char('n')));
        assert_eq!(s.mode, Mode::Normal);
        assert_eq!(m.headers.len(), 1);
        handle_normal(&mut s, &mut m, key(KeyCode::Char('d')));
        handle_confirm_delete(&mut s, &mut m, key(KeyCode::Char('y')));
        assert_eq!(m.headers.len(), 0);
    }

    #[test]
    fn h_and_l_move_cells() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::QueryName(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert directly
        handle_insert(&mut s, &mut m, key(KeyCode::Esc)); // -> cell focus
        let first = s.cell.unwrap();
        handle_normal(&mut s, &mut m, key(KeyCode::Char('l')));
        assert!(s.cell.unwrap() > first);
        handle_normal(&mut s, &mut m, key(KeyCode::Char('h')));
        assert_eq!(s.cell.unwrap(), first);
    }

    #[test]
    fn edit_text_cell_commits() {
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::Name));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert directly
        assert!(matches!(s.mode, Mode::Insert(_)));
        handle_insert(&mut s, &mut m, key(KeyCode::Char('x')));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter));
        assert_eq!(m.name, "tx");
    }

    #[test]
    fn tab_commits_and_jumps_to_next_text_cell_in_insert() {
        let c = json_get(
            r#"{ "name":"t","method":"GET",
                 "url":{"protocol":"https","host":"h","path":["x"],
                        "query":[{"name":"page","type":"1","description":"d","required":false}]},
                 "headers":[],"responses":[] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::QueryName(_)));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert (prefilled "page")
        assert!(matches!(s.mode, Mode::Insert(_)));
        handle_insert(&mut s, &mut m, key(KeyCode::Tab)); // commit + jump to the value cell
        assert_eq!(m.url.query[0].name, "page"); // committed unchanged
        assert!(matches!(s.focused_field_pub(), Some(Field::QueryType(_))));
        assert!(
            matches!(s.mode, Mode::Insert(_)),
            "stays in insert on the next text cell"
        );
        // typing continues into the value cell (prefilled "1")
        handle_insert(&mut s, &mut m, key(KeyCode::Char('2')));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter));
        assert_eq!(m.url.query[0].dtype, "12");
    }

    #[test]
    fn method_cycles_when_url_expanded() {
        let mut m = model();
        let mut s = UiState::new(&m);
        s.expanded = Some(Expand::Url);
        s.refresh(&m);
        goto(&mut s, |f| matches!(f, Field::Method));
        // focus the method enum cell
        let mi = s
            .current_row()
            .unwrap()
            .cells
            .iter()
            .position(|c| matches!(c.field, Field::Method))
            .unwrap();
        s.cell = Some(mi);
        handle_normal(&mut s, &mut m, key(KeyCode::Enter));
        assert_ne!(method_str(&m.method), "GET");
    }

    #[test]
    fn quit_clean_and_dirty() {
        let mut m = model();
        let mut s = UiState::new(&m);
        s.dirty = false;
        assert_eq!(
            handle_normal(&mut s, &mut m, key(KeyCode::Char('q'))),
            Action::Quit
        );
        s.dirty = true;
        assert_eq!(
            handle_normal(&mut s, &mut m, key(KeyCode::Char('q'))),
            Action::None
        );
    }

    #[test]
    fn save_clears_dirty() {
        let dir = std::env::temp_dir().join("apic_tui_ri_save");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("c.json");
        let m = model();
        let mut s = UiState::new(&m);
        s.dirty = true;
        apply_save(&mut s, &m, &path);
        assert!(!s.dirty);
        assert!(path.exists());
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn a_adds_child_under_object_field() {
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"https","host":"h","path":["x"]},"headers":[],
                 "request":{"type":"object","schema":[
                    {"name":"wrap","type":"object","default":null,"description":"d","required":true,
                     "properties":[{"name":"a","type":"string","default":null,"description":"d","required":false}]}
                 ]},
                 "responses":[] }"#,
            None,
        ).unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        // focus the object field "wrap" (top-level schema field, path [0])
        goto(
            &mut s,
            |f| matches!(f, Field::SchemaName(BodyLoc::Request, p) if p == &vec![0]),
        );
        let before = m.request.as_ref().unwrap().schema[0].properties.len();
        handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
        assert_eq!(
            m.request.as_ref().unwrap().schema[0].properties.len(),
            before + 1
        );
    }

    #[test]
    fn g_generates_example_for_request_body() {
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
                 "request":{"type":"object","schema":[
                    {"name":"status","type":"int","default":null,"description":"d","required":true}
                 ]},
                 "responses":[] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        // focus a row in the REQUEST body section (its schema field)
        goto(&mut s, |f| {
            matches!(f, Field::SchemaName(BodyLoc::Request, _))
        });
        s.cell = None;
        handle_normal(&mut s, &mut m, key(KeyCode::Char('g')));
        assert!(m.request.as_ref().unwrap().example.contains("\"status\""));
    }

    /// Builds a model whose request body carries `example` and a `UiState`
    /// parked on a request schema row, ready for a `G` keypress.
    fn request_example_state(example: &str) -> (UiState, EditModel) {
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
                 "request":{"type":"object","schema":[
                    {"name":"placeholder","type":"int","default":null,"description":"d","required":true}
                 ]},
                 "responses":[] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        m.request.as_mut().unwrap().example = example.to_string();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| {
            matches!(f, Field::SchemaName(BodyLoc::Request, _))
        });
        s.cell = None;
        (s, m)
    }

    #[test]
    fn cap_g_infers_schema_from_request_example() {
        let (mut s, mut m) = request_example_state(r#"{"name":"john","age":1}"#);
        handle_normal(&mut s, &mut m, key(KeyCode::Char('G')));
        let schema = &m.request.as_ref().unwrap().schema;
        assert_eq!(
            schema.iter().find(|f| f.name == "name").unwrap().dtype,
            "string"
        );
        assert_eq!(
            schema.iter().find(|f| f.name == "age").unwrap().dtype,
            "int"
        );
        assert!(m.last_error.is_none());
    }

    #[test]
    fn cap_g_reports_schema_error_on_invalid_example() {
        let (mut s, mut m) = request_example_state("{bad");
        handle_normal(&mut s, &mut m, key(KeyCode::Char('G')));
        assert!(m.last_error.is_some());
        assert!(
            s.status.starts_with("schema error"),
            "status was: {}",
            s.status
        );
    }

    #[test]
    fn cap_g_infers_schema_from_response_example() {
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
                 "responses":[{"code":200,"description":"ok","type":"object","schema":[
                    {"name":"placeholder","type":"int","default":null,"description":"d","required":true}
                 ]}] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        m.responses[0].example = r#"{"token":"abc"}"#.to_string();
        let mut s = UiState::new(&m);
        s.expanded = Some(Expand::Response(0));
        s.refresh(&m);
        goto(&mut s, |f| {
            matches!(f, Field::SchemaName(BodyLoc::Response(0), _))
        });
        s.cell = None;
        handle_normal(&mut s, &mut m, key(KeyCode::Char('G')));
        let schema = &m.responses[0].schema;
        assert_eq!(
            schema.iter().find(|f| f.name == "token").unwrap().dtype,
            "string"
        );
        assert!(m.last_error.is_none());
    }

    #[test]
    fn cap_g_is_a_noop_when_cursor_not_in_a_body() {
        // No body under the cursor (default focus on the endpoint section):
        // `G` resolves no `BodyLoc` and must do nothing.
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
                 "responses":[] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        handle_normal(&mut s, &mut m, key(KeyCode::Char('G')));
        assert!(m.request.is_none());
        assert!(m.last_error.is_none());
    }

    #[test]
    fn e_key_opens_example_editor_for_request_body() {
        let (mut s, mut m) = request_example_state("");
        let action = handle_normal(&mut s, &mut m, key(KeyCode::Char('e')));
        assert_eq!(
            action,
            Action::OpenExample(Field::BodyExample(BodyLoc::Request), String::new())
        );
    }

    #[test]
    fn e_key_is_noop_when_cursor_not_in_a_body() {
        // Default focus is the endpoint section (not a body): `e` does nothing.
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
                 "responses":[] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        let action = handle_normal(&mut s, &mut m, key(KeyCode::Char('e')));
        assert_eq!(action, Action::None);
    }

    #[test]
    fn e_key_opens_example_editor_for_response_body() {
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
                 "responses":[{"code":200,"description":"ok","type":"object","schema":[
                    {"name":"placeholder","type":"int","default":null,"description":"d","required":true}
                 ]}] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        s.expanded = Some(Expand::Response(0));
        s.refresh(&m);
        goto(&mut s, |f| {
            matches!(f, Field::SchemaName(BodyLoc::Response(0), _))
        });
        s.cell = None;
        let action = handle_normal(&mut s, &mut m, key(KeyCode::Char('e')));
        assert_eq!(
            action,
            Action::OpenExample(Field::BodyExample(BodyLoc::Response(0)), String::new())
        );
    }

    #[test]
    fn g_wraps_array_body_example_in_an_array() {
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
                 "request":{"type":"object[]","schema":[
                    {"name":"id","type":"int","default":null,"description":"d","required":true}
                 ]},
                 "responses":[] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        goto(&mut s, |f| {
            matches!(f, Field::SchemaName(BodyLoc::Request, _))
        });
        s.cell = None;
        handle_normal(&mut s, &mut m, key(KeyCode::Char('g')));
        let ex = m.request.as_ref().unwrap().example.clone();
        let v: serde_json::Value = serde_json::from_str(&ex).unwrap();
        assert!(
            v.is_array(),
            "object[] body should generate an array example"
        );
        assert_eq!(v[0]["id"], serde_json::json!(0));
    }

    #[test]
    fn body_type_toggles_between_object_and_array() {
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"h","host":"h","path":["x"]},"headers":[],
                 "request":{"type":"object","schema":[]},
                 "responses":[] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        // Expand the REQUEST title so the `type` row exists, focus its enum cell.
        s.expanded = Some(Expand::Request);
        s.refresh(&m);
        goto(&mut s, |f| matches!(f, Field::BodyDtype(BodyLoc::Request)));
        let ti = s
            .current_row()
            .unwrap()
            .cells
            .iter()
            .position(|c| matches!(c.field, Field::BodyDtype(_)))
            .unwrap();
        s.cell = Some(ti);
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // object -> object[]
        assert_eq!(m.request.as_ref().unwrap().dtype, "object[]");
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // object[] -> object
        assert_eq!(m.request.as_ref().unwrap().dtype, "object");
    }

    #[test]
    fn a_on_empty_query_title_adds_first_item() {
        // model() has no query — the QUERY section is empty
        let c = json_get(
            r#"{ "name":"t","method":"GET",
                 "url":{"protocol":"https","host":"h","path":["x"]},
                 "headers":[],"responses":[] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        // land on the QUERY title row
        let (si, ri) = s
            .sections
            .iter()
            .enumerate()
            .find_map(|(si, sec)| {
                (sec.title == "QUERY").then(|| {
                    (
                        si,
                        sec.rows
                            .iter()
                            .position(|r| r.kind == RowKind::Title)
                            .unwrap(),
                    )
                })
            })
            .unwrap();
        s.sec = si;
        s.row = ri;
        s.cell = None;
        // Enter does nothing on the title
        handle_normal(&mut s, &mut m, key(KeyCode::Enter));
        assert_eq!(m.url.query.len(), 0);
        // a adds the first query
        handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
        assert_eq!(m.url.query.len(), 1);
    }

    #[test]
    fn add_response_expands_and_focuses_code() {
        let c = json_get(
            r#"{ "name":"t","method":"GET",
                 "url":{"protocol":"https","host":"h","path":["x"]},"headers":[],
                 "responses":[{"code":200,"description":"ok","schema":[]}] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        // land on the "+ add response" affordance row
        let (si, ri) = s
            .sections
            .iter()
            .enumerate()
            .find_map(|(si, sec)| {
                (sec.add == Some(Field::ResponseAdd)
                    && sec
                        .rows
                        .iter()
                        .any(|r| r.cells.iter().any(|c| c.value == "+ add response")))
                .then(|| {
                    (
                        si,
                        sec.rows
                            .iter()
                            .position(|r| r.cells.iter().any(|c| c.value == "+ add response"))
                            .unwrap(),
                    )
                })
            })
            .unwrap();
        s.sec = si;
        s.row = ri;
        s.cell = None;
        handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
        assert_eq!(m.responses.len(), 2);
        assert!(matches!(s.mode, Mode::Insert(_)));
        assert!(matches!(
            s.focused_field_pub(),
            Some(Field::ResponseCode(1))
        ));
        // the new response is prefilled with the default code 200
        assert_eq!(m.responses[1].code, "200");
        // the prefill is editable: clear it, then type a new code
        handle_insert(&mut s, &mut m, key(KeyCode::Backspace));
        handle_insert(&mut s, &mut m, key(KeyCode::Backspace));
        handle_insert(&mut s, &mut m, key(KeyCode::Backspace));
        handle_insert(&mut s, &mut m, key(KeyCode::Char('4')));
        handle_insert(&mut s, &mut m, key(KeyCode::Char('2')));
        handle_insert(&mut s, &mut m, key(KeyCode::Char('9')));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter));
        assert_eq!(m.responses[1].code, "429");
    }

    #[test]
    fn dirty_tracks_real_changes_against_baseline() {
        let mut m = model(); // name == "t"
        let mut s = UiState::new(&m);
        assert!(!s.dirty);
        // edit name "t" -> "tx"
        goto(&mut s, |f| matches!(f, Field::Name));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert (prefilled "t")
        handle_insert(&mut s, &mut m, key(KeyCode::Char('x')));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter));
        assert!(s.dirty);
        // revert "tx" -> "t" clears dirty
        handle_normal(&mut s, &mut m, key(KeyCode::Enter)); // insert (prefilled "tx")
        handle_insert(&mut s, &mut m, key(KeyCode::Backspace));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter));
        assert!(!s.dirty, "reverting to the original value clears dirty");
    }

    #[test]
    fn save_updates_baseline() {
        let dir = std::env::temp_dir().join("apic_tui_baseline");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("c.json");
        let mut m = model();
        let mut s = UiState::new(&m);
        goto(&mut s, |f| matches!(f, Field::Name));
        handle_normal(&mut s, &mut m, key(KeyCode::Enter));
        handle_insert(&mut s, &mut m, key(KeyCode::Char('z')));
        handle_insert(&mut s, &mut m, key(KeyCode::Enter));
        assert!(s.dirty);
        apply_save(&mut s, &m, &path);
        assert!(!s.dirty);
        // a subsequent refresh with no change keeps it clean (baseline moved)
        s.refresh(&m);
        assert!(!s.dirty);
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn a_adds_sibling_for_non_object_field() {
        let c = json_get(
            r#"{ "name":"t","method":"POST",
                 "url":{"protocol":"https","host":"h","path":["x"]},"headers":[],
                 "request":{"type":"object","schema":[
                    {"name":"s","type":"string","default":null,"description":"d","required":false}
                 ]},
                 "responses":[] }"#,
            None,
        )
        .unwrap();
        let mut m = EditModel::from_contract(c);
        let mut s = UiState::new(&m);
        goto(
            &mut s,
            |f| matches!(f, Field::SchemaName(BodyLoc::Request, p) if p == &vec![0]),
        );
        let before = m.request.as_ref().unwrap().schema.len();
        handle_normal(&mut s, &mut m, key(KeyCode::Char('a')));
        assert_eq!(m.request.as_ref().unwrap().schema.len(), before + 1); // sibling at top level
    }
}
