use std::fmt;
use std::io::Write;

use console::style;
use inquire::InquireError;
use serde::Serialize;

use crate::cli::{DEFAULT_MAX_RETRIES, DEFAULT_RATE_LIMIT_RETRIES, ListArgs};
use crate::error::{CruiseError, Result};
use crate::multiline_input::{InputResult, prompt_multiline};
use crate::session::{SessionManager, SessionPhase, SessionState, WorkspaceMode};
use crate::workflow::{list_skippable_after_pr_steps, list_skippable_steps};

/// CLI-only DTO for JSON output. Stable machine-readable form of `SessionState`.
/// `phase` is always a plain string; `phase_error` carries the failure message for Failed sessions.
#[derive(Debug, Serialize)]
struct ListSessionJson {
    id: String,
    base_dir: String,
    phase: &'static str,
    phase_error: Option<String>,
    plan_error: Option<String>,
    config_source: String,
    input: String,
    title: Option<String>,
    current_step: Option<String>,
    created_at: String,
    completed_at: Option<String>,
    worktree_path: Option<String>,
    worktree_branch: Option<String>,
    workspace_mode: WorkspaceMode,
    target_branch: Option<String>,
    pr_url: Option<String>,
    config_path: Option<String>,
    updated_at: Option<String>,
    awaiting_input: bool,
    plan_available: bool,
}

/// `Failed(msg)` is normalized to `phase = "Failed"` + `phase_error = Some(msg)`.
#[cfg(test)]
fn session_to_json(session: SessionState) -> ListSessionJson {
    session_to_json_with_plan_availability(session, false)
}

fn session_to_json_with_plan_availability(
    session: SessionState,
    plan_available: bool,
) -> ListSessionJson {
    let (phase, phase_error): (&'static str, Option<String>) = match session.phase {
        SessionPhase::Draft => ("Draft", None),
        SessionPhase::AwaitingInput => ("AwaitingInput", None),
        SessionPhase::AwaitingApproval => ("AwaitingApproval", None),
        SessionPhase::Planned => ("Planned", None),
        SessionPhase::Running => ("Running", None),
        SessionPhase::Completed => ("Completed", None),
        SessionPhase::Failed(msg) => ("Failed", Some(msg)),
        SessionPhase::Suspended => ("Suspended", None),
    };
    ListSessionJson {
        id: session.id,
        base_dir: session.base_dir.to_string_lossy().into_owned(),
        phase,
        phase_error,
        plan_error: session.plan_error,
        config_source: session.config_source,
        input: session.input,
        title: session.title,
        current_step: session.current_step,
        created_at: session.created_at,
        completed_at: session.completed_at,
        worktree_path: session
            .worktree_path
            .map(|p| p.to_string_lossy().into_owned()),
        worktree_branch: session.worktree_branch,
        workspace_mode: session.workspace_mode,
        target_branch: session.target_branch,
        pr_url: session.pr_url,
        config_path: session
            .config_path
            .map(|p| p.to_string_lossy().into_owned()),
        updated_at: session.updated_at,
        awaiting_input: session.awaiting_input,
        plan_available,
    }
}

/// Serialize a list of sessions to a JSON array (pretty-printed) followed by a newline.
#[cfg(test)]
fn write_sessions_json<W: Write>(mut writer: W, sessions: Vec<SessionState>) -> Result<()> {
    let dtos: Vec<ListSessionJson> = sessions.into_iter().map(session_to_json).collect();
    serde_json::to_writer_pretty(&mut writer, &dtos)
        .map_err(|e| CruiseError::Other(format!("JSON serialization error: {e}")))?;
    writer
        .write_all(b"\n")
        .map_err(|e| CruiseError::Other(format!("write error: {e}")))?;
    Ok(())
}

fn write_sessions_json_with_manager<W: Write>(
    mut writer: W,
    sessions: Vec<SessionState>,
    manager: &SessionManager,
) -> Result<()> {
    let dtos: Vec<ListSessionJson> = sessions
        .into_iter()
        .map(|session| {
            let plan_available = plan_available_for_session(&session, manager);
            session_to_json_with_plan_availability(session, plan_available)
        })
        .collect();
    serde_json::to_writer_pretty(&mut writer, &dtos)
        .map_err(|e| CruiseError::Other(format!("JSON serialization error: {e}")))?;
    writer
        .write_all(b"\n")
        .map_err(|e| CruiseError::Other(format!("write error: {e}")))?;
    Ok(())
}

#[expect(
    clippy::too_many_lines,
    reason = "interactive session picker with multiple action branches"
)]
pub async fn run(args: ListArgs) -> Result<()> {
    let manager = SessionManager::new(crate::paths::data_dir()?);

    if args.json {
        let mut sessions = manager.list()?;
        for s in &mut sessions {
            let _ = manager.reconcile_running_phase(s, false);
        }
        write_sessions_json_with_manager(
            std::io::BufWriter::new(std::io::stdout()),
            sessions,
            &manager,
        )?;
        return Ok(());
    }

    loop {
        let Some(mut session) = pick_session(&manager)? else {
            return Ok(());
        };

        loop {
            let plan_available = plan_available_for_session(&session, &manager);

            // Show plan.md content.
            let plan_path = session.plan_path(&manager.sessions_dir());
            if let Ok(content) = std::fs::read_to_string(&plan_path) {
                crate::display::print_bordered(&content, Some("plan.md"));
            }

            // Action menu.
            let actions = session_actions_with_plan_availability(&session, plan_available);

            // Re-shown after Generate Plan / Replan (SDK turns), which may have
            // left the terminal's foreground process group dead.
            crate::platform::reclaim_terminal_foreground();
            let action = match inquire::Select::new("Action:", actions).prompt() {
                Ok(a) => a,
                Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => "Back",
                Err(e) => return Err(CruiseError::Other(format!("selection error: {e}"))),
            };

            match action {
                "Generate Plan" => {
                    match crate::plan_cmd::generate_plan_for_draft_session(
                        &manager,
                        &mut session,
                        DEFAULT_RATE_LIMIT_RETRIES,
                    )
                    .await
                    {
                        Ok(()) => {
                            session = manager.load(&session.id)?;
                        }
                        Err(e) => {
                            eprintln!("{} Plan generation failed: {e}", style("✗").red());
                            if let Ok(reloaded) = manager.load(&session.id) {
                                session = reloaded;
                            }
                        }
                    }
                }
                "Approve" => {
                    if !plan_available || session.plan_error.is_some() {
                        eprintln!("{} plan is not ready for approval yet", style("!").yellow());
                        continue;
                    }
                    if let Err(err) =
                        crate::metadata::refresh_session_title_from_session(&manager, &mut session)
                    {
                        eprintln!("warning: failed to refresh session title: {err}");
                    }
                    session.approve();
                    crate::repo_clone::cleanup_after_approval(&manager, &mut session);
                    manager.save(&session)?;
                    eprintln!(
                        "{} Session {} approved. Run with: {}",
                        style("v").green(),
                        session.id,
                        style(format!("cruise run {}", session.id)).cyan()
                    );
                }
                "Publish as Issue" => {
                    if !plan_available || session.plan_error.is_some() {
                        eprintln!("{} plan is not ready for approval yet", style("!").yellow());
                        continue;
                    }
                    crate::platform::reclaim_terminal_foreground();
                    let default_trigger_cruise = matches!(session.phase, SessionPhase::Planned);
                    let trigger_cruise = match inquire::Confirm::new(
                        "Post an @cruise run comment after creating the issue?",
                    )
                    .with_default(default_trigger_cruise)
                    .prompt()
                    {
                        Ok(answer) => answer,
                        Err(
                            InquireError::OperationCanceled | InquireError::OperationInterrupted,
                        ) => {
                            continue;
                        }
                        Err(e) => {
                            return Err(CruiseError::Other(format!("selection error: {e}")));
                        }
                    };
                    match crate::issue_publish::publish_plan_issue_and_delete(
                        &manager,
                        session.clone(),
                        trigger_cruise,
                    ) {
                        Ok(published) => {
                            eprintln!(
                                "{} Published plan as issue: {}",
                                style("v").green(),
                                published.url
                            );
                            break;
                        }
                        Err(e) => {
                            eprintln!("{} Failed to publish plan as issue: {e}", style("x").red());
                            if let Ok(reloaded) = manager.load(&session.id) {
                                session = reloaded;
                            }
                        }
                    }
                }
                "Run" | "Resume" => {
                    let run_args = crate::cli::RunArgs {
                        session: Some(session.id.clone()),
                        all: false,
                        max_retries: DEFAULT_MAX_RETRIES,
                        rate_limit_retries: DEFAULT_RATE_LIMIT_RETRIES,
                        dry_run: false,
                        cleanup_after_pr: false,
                        no_cleanup_after_pr: false,
                    };
                    return crate::run_cmd::run(run_args).await;
                }
                "Replan" => {
                    let text = match prompt_multiline("Describe the changes needed:")? {
                        InputResult::Submitted(t) => t,
                        InputResult::Cancelled => continue,
                    };
                    crate::plan_cmd::replan_session(
                        &manager,
                        &mut session,
                        text,
                        DEFAULT_RATE_LIMIT_RETRIES,
                    )
                    .await?;
                    // Re-load so subsequent session_actions(&session) uses fresh state.
                    session = manager.load(&session.id)?;
                }
                "Open PR" => {
                    let url = session.pr_url.as_deref().ok_or_else(|| {
                        CruiseError::Other("Open PR action requires pr_url".into())
                    })?;
                    match open_pr_in_browser(url) {
                        Ok(()) => {
                            eprintln!("{} Opening PR in browser...", style("v").green());
                        }
                        Err(e) => {
                            eprintln!("{} {e}", style("x").red());
                        }
                    }
                }
                "Edit Settings" => {
                    match edit_session_settings_interactive(
                        &manager,
                        &mut session,
                        DEFAULT_RATE_LIMIT_RETRIES,
                    )
                    .await
                    {
                        Ok(()) => {
                            if let Ok(reloaded) = manager.load(&session.id) {
                                session = reloaded;
                            }
                        }
                        Err(e) => {
                            eprintln!("{} Edit settings failed: {e}", style("✗").red());
                            if let Ok(reloaded) = manager.load(&session.id) {
                                session = reloaded;
                            }
                        }
                    }
                }
                "Reset to Planned" => {
                    session.reset_to_planned();
                    manager.save(&session)?;
                    eprintln!(
                        "{} Session {} reset to Planned.",
                        style("v").green(),
                        session.id
                    );
                }
                "Delete" => {
                    if session.repo.is_some() {
                        crate::repo_clone::cleanup_session_workspace(&manager, &session);
                    }
                    manager.delete(&session.id)?;
                    eprintln!("{} Session {} deleted.", style("v").green(), session.id);
                    break;
                }
                _ => {
                    // "Back" -- return to the session list.
                    break;
                }
            }
        }
    }
}

/// Prompts the user to select a session from the list.
/// Returns `Ok(None)` if the list is empty or the user cancels.
fn pick_session(manager: &crate::session::SessionManager) -> Result<Option<SessionState>> {
    let sessions = manager.list()?;
    if sessions.is_empty() {
        eprintln!("No sessions found.");
        return Ok(None);
    }
    let labels: Vec<String> = sessions
        .iter()
        .map(|session| {
            format_session_label_with_plan_availability(
                session,
                plan_available_for_session(session, manager),
            )
        })
        .collect();
    let label_refs: Vec<&str> = labels.iter().map(std::string::String::as_str).collect();
    crate::platform::reclaim_terminal_foreground();
    let selected = match inquire::Select::new("Select a session:", label_refs).prompt() {
        Ok(s) => s,
        Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
            return Ok(None);
        }
        Err(e) => return Err(CruiseError::Other(format!("selection error: {e}"))),
    };
    let Some(idx) = labels.iter().position(|l| l.as_str() == selected) else {
        return Err(CruiseError::Other(format!(
            "selected label not found: {selected}"
        )));
    };
    Ok(Some(sessions[idx].clone()))
}

/// Returns the action menu items available for the given session.
/// "Run"/"Resume" appears for runnable phases; "Replan" only for Planned.
/// "Open PR" appears for Completed sessions that have a PR URL.
/// "Reset to Planned" appears for Running, Failed, Completed, and Suspended.
/// "Delete" and "Back" are always present (in that order) at the end.
#[cfg(test)]
fn session_actions(session: &SessionState) -> Vec<&'static str> {
    let plan_available =
        !matches!(session.phase, SessionPhase::AwaitingApproval) || session.plan_error.is_none();
    session_actions_with_plan_availability(session, plan_available)
}

fn session_actions_with_plan_availability(
    session: &SessionState,
    plan_available: bool,
) -> Vec<&'static str> {
    let mut actions = vec![];
    match &session.phase {
        SessionPhase::Draft | SessionPhase::AwaitingInput => {
            actions.push("Generate Plan");
        }
        SessionPhase::AwaitingApproval => {
            if plan_available && session.plan_error.is_none() {
                actions.push("Approve");
                actions.push("Publish as Issue");
            }
            actions.push("Edit Settings");
        }
        SessionPhase::Planned => {
            actions.push("Run");
            if plan_available && session.plan_error.is_none() {
                actions.push("Publish as Issue");
            }
            actions.push("Edit Settings");
            actions.push("Replan");
        }
        SessionPhase::Running => {
            actions.push("Resume");
            actions.push("Reset to Planned");
        }
        SessionPhase::Suspended => {
            actions.push("Resume");
            actions.push("Edit Settings");
            actions.push("Reset to Planned");
        }
        SessionPhase::Failed(_) => {
            actions.push("Run");
            actions.push("Edit Settings");
            actions.push("Reset to Planned");
        }
        SessionPhase::Completed => {
            if session.pr_url.is_some() {
                actions.push("Open PR");
            }
            actions.push("Reset to Planned");
        }
    }
    actions.push("Delete");
    actions.push("Back");
    actions
}

fn plan_available_for_session(session: &SessionState, manager: &SessionManager) -> bool {
    let plan_path = session.plan_path(&manager.sessions_dir());
    crate::metadata::plan_markdown_available(&plan_path)
}

/// Displayable choice for a skip-step multi-select.
struct StepChoice {
    label: String,
    expanded_id: String,
}

impl fmt::Display for StepChoice {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.label)
    }
}

/// Edit session settings interactively.
#[expect(clippy::too_many_lines, reason = "interactive UI with many branches")]
async fn edit_session_settings_interactive(
    manager: &SessionManager,
    session: &mut crate::session::SessionState,
    rate_limit_retries: usize,
) -> crate::error::Result<()> {
    use crate::session_edit::{CurrentStepUpdate, SessionSettingsUpdate, update_session_settings};

    // Load config to enumerate available step names for the multi-select.
    // Main-phase steps and after-pr steps are kept separate: after-pr IDs are
    // labelled so the user can tell them apart, and current_step is restricted
    // to main-phase expanded IDs (after-pr steps are not valid resume points).
    let (main_nodes, after_pr_nodes) = match manager.load_config(session) {
        Ok(config) => {
            let main = list_skippable_steps(&config).unwrap_or_default();
            let after = list_skippable_after_pr_steps(&config).unwrap_or_default();
            (main, after)
        }
        Err(_) => (Vec::new(), Vec::new()),
    };
    let main_ids: Vec<String> = main_nodes
        .iter()
        .flat_map(|n| n.expanded_step_ids.clone())
        .collect();
    let after_ids: Vec<String> = after_pr_nodes
        .iter()
        .flat_map(|n| n.expanded_step_ids.clone())
        .collect();

    let choices: Vec<StepChoice> = main_ids
        .iter()
        .map(|id| StepChoice {
            label: id.clone(),
            expanded_id: id.clone(),
        })
        .chain(after_ids.iter().map(|id| StepChoice {
            label: format!("[after-pr] {id}"),
            expanded_id: id.clone(),
        }))
        .collect();

    let skipped_steps = if choices.is_empty() {
        eprintln!("(no steps available from config — skipped_steps unchanged)");
        session.skipped_steps.clone()
    } else {
        let defaults: Vec<usize> = choices
            .iter()
            .enumerate()
            .filter(|(_, choice)| session.skipped_steps.contains(&choice.expanded_id))
            .map(|(i, _)| i)
            .collect();
        match inquire::MultiSelect::new("Steps to skip:", choices)
            .with_default(&defaults)
            .prompt()
        {
            Ok(selected) => selected.into_iter().map(|c| c.expanded_id).collect(),
            Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
                return Ok(());
            }
            Err(e) => {
                return Err(CruiseError::Other(format!("selection error: {e}")));
            }
        }
    };

    // For Failed/Suspended sessions, allow changing current_step interactively.
    // Only main-phase expanded IDs are valid resume points.
    let current_step_update = if matches!(
        &session.phase,
        SessionPhase::Failed(_) | SessionPhase::Suspended
    ) && !main_ids.is_empty()
    {
        let keep = format!(
            "Keep ({})",
            session.current_step.as_deref().unwrap_or("from beginning")
        );
        let clear = "From beginning (clear)".to_string();
        let mut choices = vec![keep.clone(), clear.clone()];
        choices.extend(main_ids);
        match inquire::Select::new("Resume from step:", choices).prompt() {
            Ok(choice) if choice == keep => CurrentStepUpdate::Unchanged,
            Ok(choice) if choice == clear => CurrentStepUpdate::Clear,
            Ok(step_name) => CurrentStepUpdate::Set(step_name),
            Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
                return Ok(());
            }
            Err(e) => {
                return Err(CruiseError::Other(format!("selection error: {e}")));
            }
        }
    } else {
        CurrentStepUpdate::Unchanged
    };

    // Keep the current explicit config path; changing config is not yet
    // supported from the interactive picker.
    let config_path = session
        .config_path
        .as_ref()
        .map(|p| p.to_string_lossy().into_owned());

    let (updated, config_changed) = update_session_settings(
        manager,
        &session.id,
        SessionSettingsUpdate {
            config_path,
            skipped_steps,
            current_step_update,
        },
    )?;
    *session = updated;

    if config_changed {
        eprintln!("{} Config changed — regenerating plan…", style("->").cyan());
        crate::platform::reclaim_terminal_foreground();
        if let Err(e) =
            crate::plan_cmd::regenerate_plan_for_session(manager, session, rate_limit_retries).await
        {
            eprintln!("{} Plan regeneration failed: {e}", style("✗").red());
            if let Ok(reloaded) = manager.load(&session.id) {
                *session = reloaded;
            }
        } else if let Ok(reloaded) = manager.load(&session.id) {
            *session = reloaded;
        }
    } else {
        eprintln!("{} Settings updated.", style("v").green());
    }

    Ok(())
}

fn open_pr_in_browser(pr_url: &str) -> crate::error::Result<()> {
    let status = std::process::Command::new("gh")
        .args(["pr", "view", pr_url, "--web"])
        .status()
        .map_err(|e| CruiseError::Other(format!("failed to run gh: {e}")))?;
    if !status.success() {
        return Err(CruiseError::Other(format!(
            "gh pr view --web exited with {status}"
        )));
    }
    Ok(())
}

#[cfg(test)]
fn format_session_label(s: &SessionState) -> String {
    let plan_available =
        !matches!(s.phase, SessionPhase::AwaitingApproval) || s.plan_error.is_none();
    format_session_label_with_plan_availability(s, plan_available)
}

fn format_session_label_with_plan_availability(s: &SessionState, plan_available: bool) -> String {
    let (icon, phase_str) = match &s.phase {
        SessionPhase::Draft => (style("○").dim(), style("Draft").dim()),
        SessionPhase::AwaitingInput => (style("?").yellow(), style("Awaiting Input").yellow()),
        SessionPhase::AwaitingApproval if s.plan_error.is_some() => {
            (style("✗").red(), style("Plan Failed").red())
        }
        SessionPhase::AwaitingApproval if !plan_available => {
            (style("-").yellow(), style("Planning").yellow())
        }
        SessionPhase::AwaitingApproval => {
            (style("o").magenta(), style("Awaiting Approval").magenta())
        }
        SessionPhase::Planned => (style("o").cyan(), style("Planned").cyan()),
        SessionPhase::Running => (style(">").yellow(), style("Running").yellow()),
        SessionPhase::Completed => (style("v").green(), style("Completed").green()),
        SessionPhase::Failed(_) => (style("x").red(), style("Failed").red()),
        SessionPhase::Suspended => (style("||").yellow(), style("Suspended").yellow()),
    };
    let date = format_session_date(&s.id);
    let suffix = format_suffix(s);
    let input_preview = crate::display::truncate(s.title_or_input(), 60);
    format!("{icon} {date} {phase_str} {input_preview}{suffix}")
}

/// "`YYYYMMDDHHmmss`" -> "MM/DD HH:MM"
fn format_session_date(id: &str) -> String {
    let (Some(month), Some(day), Some(hour), Some(min)) =
        (id.get(4..6), id.get(6..8), id.get(8..10), id.get(10..12))
    else {
        return id.to_string();
    };
    format!("{month}/{day} {hour}:{min}")
}

/// Returns " \[`step_name`\]" for Running/Suspended, or " PR#N" for Completed with PR URL.
fn format_suffix(s: &SessionState) -> String {
    match &s.phase {
        SessionPhase::Running | SessionPhase::Suspended => s
            .current_step
            .as_ref()
            .map(|step| format!(" [{step}]"))
            .unwrap_or_default(),
        SessionPhase::Completed => s
            .pr_url
            .as_ref()
            .map(|url| {
                let num = url.trim_end_matches('/').rsplit('/').next().unwrap_or("");
                format!(" PR#{num}")
            })
            .unwrap_or_default(),
        _ => String::new(),
    }
}

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

    // -----------------------------------------------------------------------
    // session_actions
    // -----------------------------------------------------------------------

    #[test]
    fn test_session_actions_planned_has_run_and_replan() {
        // Given: Planned phase
        let session = make_session("20260306143000", "task", SessionPhase::Planned);

        // When
        let actions = session_actions(&session);

        // Then: contains "Run" and "Replan"; also contains "Delete" and "Back"
        assert!(
            actions.contains(&"Run"),
            "Planned should have Run: {actions:?}"
        );
        assert!(
            actions.contains(&"Replan"),
            "Planned should have Replan: {actions:?}"
        );
        assert!(
            actions.contains(&"Delete"),
            "should always have Delete: {actions:?}"
        );
        assert!(
            actions.contains(&"Back"),
            "should always have Back: {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_planned_has_no_resume() {
        // Given: Planned phase
        let session = make_session("20260306143000", "task", SessionPhase::Planned);

        // When
        let actions = session_actions(&session);

        // Then: "Resume" is absent (Run is used for a fresh start, not Resume)
        assert!(
            !actions.contains(&"Resume"),
            "Planned should NOT have Resume: {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_running_has_resume_not_replan() {
        // Given: Running phase
        let session = make_session("20260306143000", "task", SessionPhase::Running);

        // When
        let actions = session_actions(&session);

        // Then: "Resume" is present but "Replan" is absent
        assert!(
            actions.contains(&"Resume"),
            "Running should have Resume: {actions:?}"
        );
        assert!(
            !actions.contains(&"Replan"),
            "Running should NOT have Replan: {actions:?}"
        );
        assert!(
            !actions.contains(&"Run"),
            "Running should NOT have Run (use Resume): {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_failed_has_run_not_replan() {
        // Given: Failed phase
        let session = make_session(
            "20260306143000",
            "task",
            SessionPhase::Failed("some error".to_string()),
        );

        // When
        let actions = session_actions(&session);

        // Then: "Run" is present but "Replan" is absent
        assert!(
            actions.contains(&"Run"),
            "Failed should have Run: {actions:?}"
        );
        assert!(
            !actions.contains(&"Replan"),
            "Failed should NOT have Replan: {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_completed_has_no_run_no_replan_has_reset() {
        // Given: Completed phase, no pr_url
        let session = make_session("20260306143000", "task", SessionPhase::Completed);

        // When
        let actions = session_actions(&session);

        // Then: "Run", "Resume", and "Replan" are absent; "Reset to Planned" is present
        assert!(
            !actions.contains(&"Run"),
            "Completed should NOT have Run: {actions:?}"
        );
        assert!(
            !actions.contains(&"Resume"),
            "Completed should NOT have Resume: {actions:?}"
        );
        assert!(
            !actions.contains(&"Replan"),
            "Completed should NOT have Replan: {actions:?}"
        );
        assert!(
            actions.contains(&"Reset to Planned"),
            "Completed should have Reset to Planned: {actions:?}"
        );
        assert!(
            actions.contains(&"Delete"),
            "should always have Delete: {actions:?}"
        );
        assert!(
            actions.contains(&"Back"),
            "should always have Back: {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_planned_run_before_replan() {
        // Given: Planned phase
        let session = make_session("20260306143000", "task", SessionPhase::Planned);

        // When
        let actions = session_actions(&session);

        // Then: "Run" appears before "Replan" (primary action first)
        let run_pos = actions
            .iter()
            .position(|&a| a == "Run")
            .unwrap_or_else(|| panic!("unexpected None"));
        let replan_pos = actions
            .iter()
            .position(|&a| a == "Replan")
            .unwrap_or_else(|| panic!("unexpected None"));
        assert!(
            run_pos < replan_pos,
            "Run should come before Replan in actions list"
        );
    }

    #[test]
    fn test_session_actions_delete_and_back_always_at_end() {
        // Given: Delete and Back are the last two entries across all phases
        let sessions = [
            make_session("20260306143000", "task", SessionPhase::AwaitingApproval),
            make_session("20260306143000", "task", SessionPhase::Planned),
            make_session("20260306143000", "task", SessionPhase::Running),
            make_session("20260306143000", "task", SessionPhase::Completed),
            make_session(
                "20260306143000",
                "task",
                SessionPhase::Failed("err".to_string()),
            ),
        ];

        for session in &sessions {
            let phase = &session.phase;
            // When
            let actions = session_actions(session);
            let len = actions.len();

            // Then: Back is last, Delete is second-to-last
            assert!(
                len >= 2,
                "actions must have at least 2 items for {phase:?}: {actions:?}"
            );
            assert_eq!(
                actions[len - 1],
                "Back",
                "Back should be last for {phase:?}: {actions:?}"
            );
            assert_eq!(
                actions[len - 2],
                "Delete",
                "Delete should be second-to-last for {phase:?}: {actions:?}"
            );
        }
    }

    fn make_session(id: &str, input: &str, phase: SessionPhase) -> SessionState {
        let mut s = SessionState::new(
            id.to_string(),
            PathBuf::from("/tmp"),
            "cruise.yaml".to_string(),
            input.to_string(),
        );
        s.phase = phase;
        s
    }

    // -----------------------------------------------------------------------
    // format_session_date
    // -----------------------------------------------------------------------

    #[test]
    fn test_format_session_date_standard_id_returns_mm_dd_hh_mm() {
        // Given: standard 14-digit session ID
        let id = "20260306143000";

        // When
        let result = format_session_date(id);

        // Then: converted to "MM/DD HH:MM" format
        assert_eq!(result, "03/06 14:30");
    }

    #[test]
    fn test_format_session_date_twelve_digit_id_is_accepted() {
        // Given: 12-digit (no seconds) ID
        let id = "202603061430";

        // When
        let result = format_session_date(id);

        // Then: converted to "03/06 14:30"
        assert_eq!(result, "03/06 14:30");
    }

    #[test]
    fn test_format_session_date_midnight() {
        // Given: session at midnight (00:00)
        let id = "20260101000000";

        // When
        let result = format_session_date(id);

        // Then
        assert_eq!(result, "01/01 00:00");
    }

    // -----------------------------------------------------------------------
    // format_suffix
    // -----------------------------------------------------------------------

    #[test]
    fn test_format_suffix_running_with_step_returns_step_bracket() {
        // Given: Running phase, current_step present
        let mut s = make_session("20260306143000", "add feature", SessionPhase::Running);
        s.current_step = Some("implement".to_string());

        // When
        let result = format_suffix(&s);

        // Then: "[implement]" format
        assert_eq!(result, " [implement]");
    }

    #[test]
    fn test_format_suffix_running_without_step_returns_empty() {
        // Given: Running phase, no current_step
        let s = make_session("20260306143000", "add feature", SessionPhase::Running);

        // When
        let result = format_suffix(&s);

        // Then: empty string
        assert_eq!(result, "");
    }

    #[test]
    fn test_format_suffix_completed_with_pr_url_returns_pr_number() {
        // Given: Completed phase, PR URL present
        let mut s = make_session("20260306143000", "add feature", SessionPhase::Completed);
        s.pr_url = Some("https://github.com/owner/repo/pull/42".to_string());

        // When
        let result = format_suffix(&s);

        // Then: "PR#42" format
        assert_eq!(result, " PR#42");
    }

    #[test]
    fn test_format_suffix_completed_without_pr_url_returns_empty() {
        // Given: Completed phase, no PR URL
        let s = make_session("20260306143000", "add feature", SessionPhase::Completed);

        // When
        let result = format_suffix(&s);

        // Then: empty string
        assert_eq!(result, "");
    }

    #[test]
    fn test_format_suffix_planned_returns_empty() {
        // Given: Planned phase
        let s = make_session("20260306143000", "add feature", SessionPhase::Planned);

        // When
        let result = format_suffix(&s);

        // Then: empty string
        assert_eq!(result, "");
    }

    #[test]
    fn test_format_suffix_failed_returns_empty() {
        // Given: Failed phase
        let s = make_session(
            "20260306143000",
            "add feature",
            SessionPhase::Failed("timeout".to_string()),
        );

        // When
        let result = format_suffix(&s);

        // Then: empty string
        assert_eq!(result, "");
    }

    // -----------------------------------------------------------------------
    // format_session_label (expected values for new format)
    // -----------------------------------------------------------------------

    /// Helper to strip ANSI escapes and verify label content.
    fn strip(s: &str) -> String {
        console::strip_ansi_codes(s).to_string()
    }

    #[test]
    fn test_format_session_label_planned_contains_icon_date_phase_input() {
        // Given: Planned session
        let s = make_session(
            "20260306143000",
            "add hello world feature",
            SessionPhase::Planned,
        );

        // When
        let label = strip(&format_session_label(&s));

        // Then: contains icon, date, phase, and input
        assert!(label.contains('o'), "should contain o icon: {label}");
        assert!(
            label.contains("03/06 14:30"),
            "should contain date: {label}"
        );
        assert!(label.contains("Planned"), "should contain phase: {label}");
        assert!(
            label.contains("add hello world feature"),
            "should contain input: {label}"
        );
    }

    #[test]
    fn test_format_session_label_running_contains_running_icon_and_step() {
        // Given: Running phase, current_step present
        let mut s = make_session("20260307150000", "implement auth", SessionPhase::Running);
        s.current_step = Some("test".to_string());

        // When
        let label = strip(&format_session_label(&s));

        // Then: contains > icon and step info
        assert!(label.contains('>'), "should contain > icon: {label}");
        assert!(label.contains("Running"), "should contain Running: {label}");
        assert!(label.contains("[test]"), "should contain step: {label}");
    }

    #[test]
    fn test_format_session_label_completed_with_pr_contains_checkmark_and_pr() {
        // Given: Completed phase, PR URL present
        let mut s = make_session("20260307090000", "refactor db", SessionPhase::Completed);
        s.pr_url = Some("https://github.com/owner/repo/pull/42".to_string());

        // When
        let label = strip(&format_session_label(&s));

        // Then: contains v icon and PR number
        assert!(label.contains('v'), "should contain v icon: {label}");
        assert!(
            label.contains("Completed"),
            "should contain Completed: {label}"
        );
        assert!(label.contains("PR#42"), "should contain PR#42: {label}");
    }

    #[test]
    fn test_format_session_label_failed_contains_cross_icon() {
        // Given: Failed phase
        let s = make_session(
            "20260307103000",
            "fix login bug",
            SessionPhase::Failed("exit 1".to_string()),
        );

        // When
        let label = strip(&format_session_label(&s));

        // Then: contains x icon
        assert!(label.contains('x'), "should contain x icon: {label}");
        assert!(label.contains("Failed"), "should contain Failed: {label}");
    }

    #[test]
    fn test_format_session_label_long_input_is_truncated() {
        // Given: very long input
        let long_input = "a".repeat(200);
        let s = make_session("20260306143000", &long_input, SessionPhase::Planned);

        // When
        let label = strip(&format_session_label(&s));

        // Then: contains ellipsis "..." and total label length is 200 chars or less
        assert!(
            label.contains("..."),
            "long input should be truncated: {label}"
        );
    }

    #[test]
    fn test_format_session_label_prefers_title_over_input() {
        // Given: a session with both raw input and a generated title
        let mut s = make_session(
            "20260306143000",
            "raw task input that should not be the primary label",
            SessionPhase::Planned,
        );
        s.title = Some("Generated session title".to_string());

        // When
        let label = strip(&format_session_label(&s));

        // Then: the generated title is shown instead of the raw input
        assert!(
            label.contains("Generated session title"),
            "should contain generated title: {label}"
        );
        assert!(
            !label.contains("raw task input that should not be the primary label"),
            "should not contain raw input when title is present: {label}"
        );
    }

    #[test]
    fn test_format_session_label_falls_back_to_input_when_title_missing() {
        // Given: a session without a generated title
        let s = make_session(
            "20260306143000",
            "raw task input remains visible",
            SessionPhase::Planned,
        );

        // When
        let label = strip(&format_session_label(&s));

        // Then: the raw input remains the visible fallback
        assert!(
            label.contains("raw task input remains visible"),
            "should contain raw input fallback: {label}"
        );
    }

    // -----------------------------------------------------------------------
    // session_actions -- Reset to Planned coverage
    // -----------------------------------------------------------------------

    // -----------------------------------------------------------------------
    // session_actions -- Suspended
    // -----------------------------------------------------------------------

    #[test]
    fn test_session_actions_suspended_exact() {
        // Given / When / Then: Suspended action list matches expectations
        assert_eq!(
            session_actions(&make_session("test", "test", SessionPhase::Suspended)),
            vec![
                "Resume",
                "Edit Settings",
                "Reset to Planned",
                "Delete",
                "Back"
            ]
        );
    }

    // -----------------------------------------------------------------------
    // format_suffix -- Suspended
    // -----------------------------------------------------------------------

    #[test]
    fn test_format_suffix_suspended_with_step_returns_step_bracket() {
        // Given: Suspended phase, current_step present
        let mut s = make_session("20260310143000", "add feature", SessionPhase::Suspended);
        s.current_step = Some("implement".to_string());

        // When
        let result = format_suffix(&s);

        // Then: "[implement]" format
        assert_eq!(result, " [implement]");
    }

    #[test]
    fn test_format_suffix_suspended_without_step_returns_empty() {
        // Given: Suspended phase, no current_step
        let s = make_session("20260310143000", "add feature", SessionPhase::Suspended);

        // When
        let result = format_suffix(&s);

        // Then: empty string
        assert_eq!(result, "");
    }

    // -----------------------------------------------------------------------
    // format_session_label -- Suspended
    // -----------------------------------------------------------------------

    #[test]
    fn test_format_session_label_suspended_contains_phase_and_step() {
        // Given: Suspended phase, current_step present
        let mut s = make_session("20260310150000", "fix auth", SessionPhase::Suspended);
        s.current_step = Some("test".to_string());

        // When
        let label = strip(&format_session_label(&s));

        // Then: contains "Suspended" phase and the suspended step name
        assert!(
            label.contains("Suspended"),
            "should contain Suspended: {label}"
        );
        assert!(label.contains("[test]"), "should contain step: {label}");
    }

    // -----------------------------------------------------------------------
    // session_actions -- Delete/Back tail check (all phases including Suspended)
    // -----------------------------------------------------------------------

    #[test]
    fn test_session_actions_delete_and_back_always_at_end_including_suspended() {
        // Given: all phases including Suspended
        let phases = [
            SessionPhase::Planned,
            SessionPhase::Running,
            SessionPhase::Completed,
            SessionPhase::Failed("err".to_string()),
            SessionPhase::Suspended,
        ];

        for phase in &phases {
            // When
            let actions = session_actions(&make_session("test", "test", phase.clone()));
            let len = actions.len();

            // Then: Back is last, Delete is second-to-last
            assert!(
                len >= 2,
                "actions must have at least 2 items for {phase:?}: {actions:?}"
            );
            assert_eq!(
                actions[len - 1],
                "Back",
                "Back should be last for {phase:?}: {actions:?}"
            );
            assert_eq!(
                actions[len - 2],
                "Delete",
                "Delete should be second-to-last for {phase:?}: {actions:?}"
            );
        }
    }

    #[test]
    fn test_session_actions_planned_exact() {
        // Given: Planned phase with a plan available
        let session = make_session("20260306143000", "task", SessionPhase::Planned);

        // When / Then: "Publish as Issue" now appears for Planned too, right after "Run"
        assert_eq!(
            session_actions(&session),
            vec![
                "Run",
                "Publish as Issue",
                "Edit Settings",
                "Replan",
                "Delete",
                "Back"
            ]
        );
    }

    #[test]
    fn test_session_actions_running_has_reset_to_planned() {
        let session = make_session("20260306143000", "task", SessionPhase::Running);
        assert_eq!(
            session_actions(&session),
            vec!["Resume", "Reset to Planned", "Delete", "Back"]
        );
    }

    #[test]
    fn test_session_actions_completed_has_reset_to_planned() {
        // Given: Completed + no pr_url
        let session = make_session("20260306143000", "task", SessionPhase::Completed);
        assert_eq!(
            session_actions(&session),
            vec!["Reset to Planned", "Delete", "Back"]
        );
    }

    #[test]
    fn test_session_actions_failed_has_run_and_reset_to_planned() {
        let session = make_session(
            "20260306143000",
            "task",
            SessionPhase::Failed("exit 1".to_string()),
        );
        assert_eq!(
            session_actions(&session),
            vec!["Run", "Edit Settings", "Reset to Planned", "Delete", "Back"]
        );
    }

    // -----------------------------------------------------------------------
    // session_actions -- Open PR coverage
    // -----------------------------------------------------------------------

    #[test]
    fn test_session_actions_completed_with_pr_url_exact_order() {
        // Given: Completed + pr_url present
        let mut session = make_session("20260306143000", "task", SessionPhase::Completed);
        session.pr_url = Some("https://github.com/owner/repo/pull/10".to_string());

        // When
        let actions = session_actions(&session);

        // Then: order is ["Open PR", "Reset to Planned", "Delete", "Back"]
        assert_eq!(
            actions,
            vec!["Open PR", "Reset to Planned", "Delete", "Back"]
        );
    }

    // -----------------------------------------------------------------------
    // open_pr_in_browser
    // -----------------------------------------------------------------------

    #[cfg(unix)]
    #[test]
    fn test_open_pr_in_browser_calls_gh_view_web() {
        use std::os::unix::fs::PermissionsExt;
        use std::{fs, io::Read};

        let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
        let bin_dir = tmp.path().join("bin");
        fs::create_dir_all(&bin_dir).unwrap_or_else(|e| panic!("{e:?}"));
        let log_path = tmp.path().join("gh.log");

        // fake gh: records args to log file then exits 0
        let script_path = bin_dir.join("gh");
        fs::write(
            &script_path,
            format!(
                "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{}\"\n",
                log_path.display()
            ),
        )
        .unwrap_or_else(|e| panic!("{e:?}"));
        let mut perms = fs::metadata(&script_path)
            .unwrap_or_else(|e| panic!("{e:?}"))
            .permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&script_path, perms).unwrap_or_else(|e| panic!("{e:?}"));

        let _guard = crate::test_binary_support::PathEnvGuard::prepend(&bin_dir);

        let url = "https://github.com/owner/repo/pull/42";
        let result = open_pr_in_browser(url);

        assert!(result.is_ok(), "should succeed: {result:?}");

        // Verify log: "pr view <url> --web" was passed
        let mut log_content = String::new();
        fs::File::open(&log_path)
            .unwrap_or_else(|e| panic!("{e:?}"))
            .read_to_string(&mut log_content)
            .unwrap_or_else(|e| panic!("{e:?}"));
        assert!(
            log_content.contains("pr view"),
            "gh should receive 'pr view': {log_content}"
        );
        assert!(
            log_content.contains(url),
            "gh should receive the PR url: {log_content}"
        );
        assert!(
            log_content.contains("--web"),
            "gh should receive '--web': {log_content}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_open_pr_in_browser_gh_failure_returns_error() {
        use std::fs;
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
        let bin_dir = tmp.path().join("bin");
        fs::create_dir_all(&bin_dir).unwrap_or_else(|e| panic!("{e:?}"));

        // fake gh: always exits 1
        let script_path = bin_dir.join("gh");
        fs::write(&script_path, "#!/bin/sh\nexit 1\n").unwrap_or_else(|e| panic!("{e:?}"));
        let mut perms = fs::metadata(&script_path)
            .unwrap_or_else(|e| panic!("{e:?}"))
            .permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&script_path, perms).unwrap_or_else(|e| panic!("{e:?}"));

        let _guard = crate::test_binary_support::PathEnvGuard::prepend(&bin_dir);

        let result = open_pr_in_browser("https://github.com/owner/repo/pull/1");

        assert!(result.is_err(), "should fail when gh exits non-zero");
    }

    // -----------------------------------------------------------------------
    // AwaitingApproval phase -- actions and labels
    // -----------------------------------------------------------------------

    #[test]
    fn test_session_actions_awaiting_approval_has_approve() {
        // Given: AwaitingApproval phase
        let session = make_session("20260311100000", "task", SessionPhase::AwaitingApproval);

        // When
        let actions = session_actions(&session);

        // Then: contains "Approve" action
        assert!(
            actions.contains(&"Approve"),
            "AwaitingApproval should have Approve: {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_awaiting_approval_has_no_run_no_resume() {
        // Given: AwaitingApproval phase
        let session = make_session("20260311100000", "task", SessionPhase::AwaitingApproval);

        // When
        let actions = session_actions(&session);

        // Then: neither "Run" nor "Resume" since it is not yet approved
        assert!(
            !actions.contains(&"Run"),
            "AwaitingApproval should NOT have Run: {actions:?}"
        );
        assert!(
            !actions.contains(&"Resume"),
            "AwaitingApproval should NOT have Resume: {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_awaiting_approval_exact_order() {
        // Given: AwaitingApproval phase
        let session = make_session("20260311100000", "task", SessionPhase::AwaitingApproval);

        // When / Then: order is Approve -> Publish as Issue -> Edit Settings -> Delete -> Back
        assert_eq!(
            session_actions(&session),
            vec![
                "Approve",
                "Publish as Issue",
                "Edit Settings",
                "Delete",
                "Back"
            ]
        );
    }

    #[test]
    fn test_session_actions_awaiting_approval_with_plan_error_hides_approve() {
        // Given: background planning failed before approval
        let mut session = make_session("20260311100002", "task", SessionPhase::AwaitingApproval);
        session.plan_error = Some("model error".to_string());

        // When
        let actions = session_actions(&session);

        // Then: approval stays gated until planning succeeds again; Edit Settings still present
        assert_eq!(actions, vec!["Edit Settings", "Delete", "Back"]);
    }

    #[test]
    fn test_session_actions_awaiting_approval_without_plan_hides_approve() {
        // Given: background planning is still in progress
        let session = make_session("20260311100004", "task", SessionPhase::AwaitingApproval);

        // When
        let actions = session_actions_with_plan_availability(&session, false);

        // Then: approval stays hidden until a real plan exists; Edit Settings still present
        assert_eq!(actions, vec!["Edit Settings", "Delete", "Back"]);
    }

    #[test]
    fn test_format_session_label_awaiting_approval_contains_phase_text() {
        // Given: AwaitingApproval phase session
        let s = make_session(
            "20260311100000",
            "pending task",
            SessionPhase::AwaitingApproval,
        );

        // When
        let label = strip(&format_session_label(&s));

        // Then: contains "Awaiting Approval" text and icon
        assert!(
            label.contains("Awaiting Approval"),
            "label should contain 'Awaiting Approval': {label}"
        );
        assert!(label.contains('o'), "label should contain o icon: {label}");
        assert!(
            label.contains("pending task"),
            "label should contain input: {label}"
        );
    }

    #[test]
    fn test_format_session_label_awaiting_approval_not_planned_text() {
        // Given: AwaitingApproval phase session
        let s = make_session(
            "20260311100001",
            "some task",
            SessionPhase::AwaitingApproval,
        );

        // When
        let label = strip(&format_session_label(&s));

        // Then: "Planned" text is absent (to avoid phase confusion)
        assert!(
            !label.contains("Planned"),
            "AwaitingApproval label should NOT contain 'Planned': {label}"
        );
    }

    #[test]
    fn test_format_session_label_awaiting_approval_with_plan_error_shows_plan_failed() {
        // Given: background planning failed before approval
        let mut s = make_session(
            "20260311100003",
            "some task",
            SessionPhase::AwaitingApproval,
        );
        s.plan_error = Some("model error".to_string());

        // When
        let label = strip(&format_session_label(&s));

        // Then: the list surfaces the failure instead of looking approval-ready
        assert!(
            label.contains("Plan Failed"),
            "label should show Plan Failed: {label}"
        );
        assert!(
            !label.contains("Awaiting Approval"),
            "label should not look approval-ready: {label}"
        );
    }

    #[test]
    fn test_format_session_label_awaiting_approval_without_plan_shows_planning() {
        // Given: background planning has started but plan.md is not ready yet
        let s = make_session(
            "20260311100005",
            "some task",
            SessionPhase::AwaitingApproval,
        );

        // When
        let label = strip(&format_session_label_with_plan_availability(&s, false));

        // Then: the list shows an in-progress label instead of approval-ready text
        assert!(
            label.contains("Planning"),
            "label should show Planning: {label}"
        );
        assert!(
            !label.contains("Awaiting Approval"),
            "label should not show Awaiting Approval: {label}"
        );
    }

    // -- format_session_label: multiline input ---------------------------------

    #[test]
    fn test_format_session_label_multiline_input_shows_first_line_only() {
        // Given: session.input contains multiple lines (e.g. input with embedded newlines)
        let s = make_session(
            "20260306143000",
            "line1\nline2\nline3",
            SessionPhase::Planned,
        );

        // When
        let label = strip(&format_session_label(&s));

        // Then: only the first line appears in the label; remaining lines are absent
        assert!(
            label.contains("line1"),
            "label must contain first line: {label}"
        );
        assert!(
            !label.contains("line2"),
            "label must NOT contain second line: {label}"
        );
        assert!(
            !label.contains("line3"),
            "label must NOT contain third line: {label}"
        );
    }

    #[test]
    fn test_format_session_label_multiline_input_does_not_contain_newline_char() {
        // Given: multi-line input
        let s = make_session(
            "20260306143000",
            "implement feature\nwith extra detail",
            SessionPhase::Planned,
        );

        // When
        let label = strip(&format_session_label(&s));

        // Then: label contains no newline characters (displayable as a single list row)
        assert!(
            !label.contains('\n'),
            "label must not contain newline character: {label:?}"
        );
    }

    // -----------------------------------------------------------------------
    // session_to_json
    // -----------------------------------------------------------------------

    #[test]
    fn test_session_to_json_failed_phase_has_phase_string_and_error() {
        // Given: a session in Failed phase with an error message
        let session = make_session(
            "20260306143000",
            "task",
            SessionPhase::Failed("db error".to_string()),
        );

        // When
        let dto = session_to_json(session);

        // Then: phase is "Failed" and phase_error contains the message
        assert_eq!(dto.phase, "Failed");
        assert_eq!(dto.phase_error, Some("db error".to_string()));
    }

    #[test]
    fn test_session_to_json_all_non_failed_phases_have_null_phase_error() {
        // Given: all non-Failed phases
        let cases = [
            (SessionPhase::AwaitingApproval, "AwaitingApproval"),
            (SessionPhase::Planned, "Planned"),
            (SessionPhase::Running, "Running"),
            (SessionPhase::Completed, "Completed"),
            (SessionPhase::Suspended, "Suspended"),
        ];

        for (phase, expected_str) in cases {
            // When
            let session = make_session("20260306143000", "task", phase);
            let dto = session_to_json(session);

            // Then: phase string matches and phase_error is None
            assert_eq!(
                dto.phase, expected_str,
                "phase string mismatch for {expected_str}"
            );
            assert_eq!(
                dto.phase_error, None,
                "phase_error should be None for {expected_str}"
            );
        }
    }

    #[test]
    fn test_session_to_json_awaiting_approval_plan_error_is_preserved() {
        // Given: a session whose background planning failed before approval
        let mut session = make_session("20260306143000", "task", SessionPhase::AwaitingApproval);
        session.plan_error = Some("planner exited 1".to_string());

        // When
        let dto = session_to_json(session);
        let value =
            serde_json::to_value(&dto).unwrap_or_else(|e| panic!("serialization failed: {e}"));

        // Then: the durable planning error is exposed separately from run-phase failures
        assert_eq!(value["phase"], "AwaitingApproval");
        assert_eq!(value["phase_error"], serde_json::Value::Null);
        assert_eq!(value["plan_error"], "planner exited 1");
        assert_eq!(value["plan_available"], false);
    }

    #[test]
    fn test_session_to_json_with_plan_availability_sets_flag() {
        // Given: an AwaitingApproval session whose plan.md is ready
        let session = make_session("20260306143001", "task", SessionPhase::AwaitingApproval);

        // When
        let dto = session_to_json_with_plan_availability(session, true);
        let value =
            serde_json::to_value(&dto).unwrap_or_else(|e| panic!("serialization failed: {e}"));

        // Then
        assert_eq!(value["plan_available"], true);
    }

    #[test]
    fn test_session_to_json_path_fields_are_strings() {
        // Given: session with base_dir and optional path fields set
        let mut session = make_session("20260306143000", "task", SessionPhase::Planned);
        session.worktree_path = Some(PathBuf::from("/tmp/worktree"));
        session.config_path = Some(PathBuf::from("/home/user/config.yaml"));

        // When
        let dto = session_to_json(session);

        // Then: path fields are serialized as strings
        assert_eq!(dto.base_dir, "/tmp");
        assert_eq!(dto.worktree_path, Some("/tmp/worktree".to_string()));
        assert_eq!(dto.config_path, Some("/home/user/config.yaml".to_string()));
    }

    #[test]
    fn test_session_to_json_null_optional_paths_are_none() {
        let session = make_session("20260306143000", "task", SessionPhase::Planned);
        let dto = session_to_json(session);
        assert_eq!(dto.worktree_path, None);
        assert_eq!(dto.config_path, None);
    }

    #[test]
    fn test_session_to_json_id_and_input_are_preserved() {
        let session = make_session(
            "20260306143000",
            "my task description",
            SessionPhase::Planned,
        );
        let dto = session_to_json(session);
        assert_eq!(dto.id, "20260306143000");
        assert_eq!(dto.input, "my task description");
    }

    // -----------------------------------------------------------------------
    // write_sessions_json
    // -----------------------------------------------------------------------

    #[test]
    fn test_write_sessions_json_empty_list_produces_empty_json_array() {
        // Given: an empty session list
        let sessions: Vec<SessionState> = vec![];
        let mut buf = Vec::new();

        // When
        write_sessions_json(&mut buf, sessions).unwrap_or_else(|e| panic!("{e:?}"));

        // Then: output parses as a JSON array with 0 entries
        let output = String::from_utf8(buf).unwrap_or_else(|e| panic!("{e:?}"));
        let value: serde_json::Value =
            serde_json::from_str(&output).unwrap_or_else(|e| panic!("{e:?}"));
        assert!(value.is_array(), "output should be a JSON array");
        assert_eq!(
            value
                .as_array()
                .unwrap_or_else(|| panic!("expected JSON array"))
                .len(),
            0,
            "empty input should produce an empty array"
        );
    }

    #[test]
    fn test_write_sessions_json_multiple_sessions_produces_array_with_correct_ids() {
        // Given: two sessions with distinct IDs
        let sessions = vec![
            make_session("20260306143000", "task A", SessionPhase::Planned),
            make_session("20260306144500", "task B", SessionPhase::Completed),
        ];
        let mut buf = Vec::new();

        // When
        write_sessions_json(&mut buf, sessions).unwrap_or_else(|e| panic!("{e:?}"));

        // Then: JSON array contains 2 entries with the expected IDs
        let output = String::from_utf8(buf).unwrap_or_else(|e| panic!("{e:?}"));
        let value: serde_json::Value =
            serde_json::from_str(&output).unwrap_or_else(|e| panic!("{e:?}"));
        let arr = value
            .as_array()
            .unwrap_or_else(|| panic!("expected JSON array"));
        assert_eq!(arr.len(), 2, "should have 2 sessions");
        assert_eq!(arr[0]["id"], "20260306143000");
        assert_eq!(arr[1]["id"], "20260306144500");
    }

    #[test]
    fn test_write_sessions_json_failed_phase_is_normalized() {
        // Given: a session in Failed phase
        let sessions = vec![make_session(
            "20260306143000",
            "task",
            SessionPhase::Failed("some error".to_string()),
        )];
        let mut buf = Vec::new();

        // When
        write_sessions_json(&mut buf, sessions).unwrap_or_else(|e| panic!("{e:?}"));

        // Then: JSON entry has phase="Failed" and phase_error="some error"
        let output = String::from_utf8(buf).unwrap_or_else(|e| panic!("{e:?}"));
        let value: serde_json::Value =
            serde_json::from_str(&output).unwrap_or_else(|e| panic!("{e:?}"));
        let entry = &value
            .as_array()
            .unwrap_or_else(|| panic!("expected JSON array"))[0];
        assert_eq!(entry["phase"], "Failed");
        assert_eq!(entry["phase_error"], "some error");
    }

    #[test]
    fn test_write_sessions_json_output_ends_with_newline() {
        let sessions: Vec<SessionState> = vec![];
        let mut buf = Vec::new();
        write_sessions_json(&mut buf, sessions).unwrap_or_else(|e| panic!("{e:?}"));
        assert!(
            buf.ends_with(b"\n"),
            "JSON output should end with a newline"
        );
    }

    // -----------------------------------------------------------------------
    // SessionPhase::Draft -- actions, label, and JSON
    // -----------------------------------------------------------------------

    #[test]
    fn test_session_actions_draft_exact() {
        // Given: Draft phase session
        let session = make_session("20260523100000", "my draft", SessionPhase::Draft);

        // When
        let actions = session_actions(&session);

        // Then: only Generate Plan, Delete, and Back are offered
        assert_eq!(actions, vec!["Generate Plan", "Delete", "Back"]);
    }

    #[test]
    fn test_format_session_label_draft_contains_draft_text() {
        // Given: Draft phase session
        let s = make_session("20260523110000", "my draft task", SessionPhase::Draft);

        // When
        let label = strip(&format_session_label(&s));

        // Then: the label contains "Draft" and the session input
        assert!(
            label.contains("Draft"),
            "Draft label should contain 'Draft': {label}"
        );
        assert!(
            label.contains("my draft task"),
            "Draft label should contain input: {label}"
        );
    }

    #[test]
    fn test_session_to_json_draft_phase_string() {
        // Given: a session in Draft phase
        let session = make_session("20260523120000", "draft task", SessionPhase::Draft);

        // When
        let dto = session_to_json(session);

        // Then: phase is "Draft" and phase_error is None
        assert_eq!(dto.phase, "Draft");
        assert_eq!(dto.phase_error, None);
    }

    // -----------------------------------------------------------------------
    // "Edit Settings" action availability
    // -----------------------------------------------------------------------

    #[test]
    fn test_session_actions_awaiting_approval_includes_edit_settings() {
        // Given: AwaitingApproval phase with a plan available
        let session = make_session("20260619100000", "task", SessionPhase::AwaitingApproval);

        // When
        let actions = session_actions_with_plan_availability(&session, true);

        // Then: "Edit Settings" is present so the user can change config/skip-steps after planning
        assert!(
            actions.contains(&"Edit Settings"),
            "AwaitingApproval should have 'Edit Settings': {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_awaiting_approval_edit_settings_available_even_without_plan() {
        // Given: AwaitingApproval phase but plan not yet available (still planning)
        let session = make_session("20260619100001", "task", SessionPhase::AwaitingApproval);

        // When
        let actions = session_actions_with_plan_availability(&session, false);

        // Then: "Edit Settings" is still present (skip-steps can be edited without a plan)
        assert!(
            actions.contains(&"Edit Settings"),
            "AwaitingApproval without plan should still have 'Edit Settings': {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_planned_includes_edit_settings() {
        // Given: Planned phase
        let session = make_session("20260619100002", "task", SessionPhase::Planned);

        // When
        let actions = session_actions(&session);

        // Then
        assert!(
            actions.contains(&"Edit Settings"),
            "Planned should have 'Edit Settings': {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_planned_edit_settings_does_not_replace_run_or_replan() {
        // Given: Planned phase
        let session = make_session("20260619100003", "task", SessionPhase::Planned);

        // When
        let actions = session_actions(&session);

        // Then: "Edit Settings" is additive — Run and Replan still exist
        assert!(
            actions.contains(&"Run"),
            "Planned should still have 'Run': {actions:?}"
        );
        assert!(
            actions.contains(&"Replan"),
            "Planned should still have 'Replan': {actions:?}"
        );
        assert!(
            actions.contains(&"Edit Settings"),
            "Planned should have 'Edit Settings': {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_completed_has_no_edit_settings() {
        // Given: Completed phase
        let session = make_session("20260619100007", "task", SessionPhase::Completed);

        // When
        let actions = session_actions(&session);

        // Then
        assert!(
            !actions.contains(&"Edit Settings"),
            "Completed should NOT have 'Edit Settings': {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_draft_has_no_edit_settings() {
        // Given: Draft phase — config editing is not meaningful before planning starts
        let session = make_session("20260619100008", "task", SessionPhase::Draft);

        // When
        let actions = session_actions(&session);

        // Then
        assert!(
            !actions.contains(&"Edit Settings"),
            "Draft should NOT have 'Edit Settings': {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_failed_has_edit_settings() {
        // Given: Failed phase — user should be able to edit skip/current_step and retry
        let session = make_session(
            "20260620100001",
            "task",
            SessionPhase::Failed("step s failed".to_string()),
        );

        // When
        let actions = session_actions(&session);

        // Then: "Edit Settings" is present for Failed phase
        assert!(
            actions.contains(&"Edit Settings"),
            "Failed should have 'Edit Settings': {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_suspended_has_edit_settings() {
        // Given: Suspended phase — user should be able to adjust skip/current_step before resuming
        let session = make_session("20260620100002", "task", SessionPhase::Suspended);

        // When
        let actions = session_actions(&session);

        // Then: "Edit Settings" is present for Suspended phase
        assert!(
            actions.contains(&"Edit Settings"),
            "Suspended should have 'Edit Settings': {actions:?}"
        );
    }

    #[test]
    fn test_session_actions_running_has_no_edit_settings() {
        // Given: Running phase — editing is prohibited while the runner is active
        let session = make_session("20260620100003", "task", SessionPhase::Running);

        // When
        let actions = session_actions(&session);

        // Then: "Edit Settings" must NOT appear (runner would race with the edit)
        assert!(
            !actions.contains(&"Edit Settings"),
            "Running should NOT have 'Edit Settings': {actions:?}"
        );
    }
}
