//! Edit service for server-less CLI.

use std::path::{Path, PathBuf};

use crate::commands::edit::{EditAction, EditChange, EditResult, Position};
use crate::config::NormalizeConfig;
use crate::edit::EditorExt;
use crate::output::OutputFormatter;
use crate::service::history::HistoryService;
use crate::shadow::{EditInfo, Shadow};
use crate::{edit, path_resolve};
use schemars::JsonSchema;
use serde::Serialize;
use server_less::cli;

// ── Internal output types (not exposed) ──────────────────────────────

struct EditOutput {
    file: String,
    symbol: Option<String>,
    operation: String,
    dry_run: bool,
    new_content: Option<String>,
}

struct UndoOutput {
    operation: String,
    dry_run: bool,
    changes: Vec<UndoChange>,
}

struct UndoChange {
    description: String,
    commit: String,
    files: Vec<String>,
    conflicts: Vec<String>,
}

struct BatchOutput {
    dry_run: bool,
    files_modified: Vec<String>,
}

// ── Internal helpers ──────────────────────────────────────────────────

/// Get the operation name for a position-based action.
fn position_op_name(prefix: &str, at: Position) -> &'static str {
    match (prefix, at) {
        ("move", Position::Before) => "move_before",
        ("move", Position::After) => "move_after",
        ("move", Position::Prepend) => "move_prepend",
        ("move", Position::Append) => "move_append",
        ("copy", Position::Before) => "copy_before",
        ("copy", Position::After) => "copy_after",
        ("copy", Position::Prepend) => "copy_prepend",
        ("copy", Position::Append) => "copy_append",
        ("insert", Position::Before) => "insert_before",
        ("insert", Position::After) => "insert_after",
        ("insert", Position::Prepend) => "prepend",
        ("insert", Position::Append) => "append",
        _ => "unknown",
    }
}

/// Apply an edit (or return a dry-run preview) and return structured output.
/// Does not print anything — callers are responsible for display.
#[allow(clippy::too_many_arguments)]
fn apply_edit(
    dry_run: bool,
    rel_path: &str,
    symbol: Option<&str>,
    operation_name: &str,
    new_content: &str,
    file_path: &Path,
    root: &Path,
    shadow_enabled: bool,
    message: Option<&str>,
) -> Result<EditOutput, String> {
    if dry_run {
        return Ok(EditOutput {
            file: rel_path.to_string(),
            symbol: symbol.map(|s| s.to_string()),
            operation: operation_name.to_string(),
            dry_run: true,
            new_content: Some(new_content.to_string()),
        });
    }

    let shadow = if shadow_enabled {
        let s = Shadow::new(root);
        if let Err(e) = s.before_edit(&[file_path]) {
            eprintln!("warning: shadow git: {}", e);
        }
        Some(s)
    } else {
        None
    };

    std::fs::write(file_path, new_content).map_err(|e| format!("Error writing file: {}", e))?;

    if let Some(ref s) = shadow {
        let target = match symbol {
            Some(sym) => format!("{}/{}", rel_path, sym),
            None => rel_path.to_string(),
        };
        let info = EditInfo {
            operation: operation_name.to_string(),
            target,
            files: vec![file_path.to_path_buf()],
            message: message.map(String::from),
            workflow: None,
        };
        if let Err(e) = s.after_edit(&info) {
            eprintln!("warning: shadow git: {}", e);
        }
    }

    Ok(EditOutput {
        file: rel_path.to_string(),
        symbol: symbol.map(|s| s.to_string()),
        operation: operation_name.to_string(),
        dry_run: false,
        new_content: None,
    })
}

/// Handle file-level operations (prepend/append to file without symbol target).
#[allow(clippy::too_many_arguments)]
fn handle_file_level(
    action: &EditAction,
    editor: &edit::Editor,
    content: &str,
    file_path: &Path,
    rel_path: &str,
    dry_run: bool,
    root: &Path,
    shadow_enabled: bool,
    message: Option<&str>,
) -> Result<EditOutput, String> {
    use crate::commands::edit::Operation;
    let (operation, new_content) = match action {
        EditAction::Insert {
            content: insert_content,
            at: Position::Prepend,
        } => (
            Operation::Insert(Position::Prepend),
            editor.prepend_to_file(content, insert_content),
        ),
        EditAction::Insert {
            content: insert_content,
            at: Position::Append,
        } => (
            Operation::Insert(Position::Append),
            editor.append_to_file(content, insert_content),
        ),
        _ => {
            return Err(
                "This operation requires a symbol target. Use a path like 'src/foo.py/MyClass'\n\
                 Hint: Only 'insert --at prepend' and 'insert --at append' work on files directly"
                    .to_string(),
            );
        }
    };

    apply_edit(
        dry_run,
        rel_path,
        None,
        &operation.to_string(),
        &new_content,
        file_path,
        root,
        shadow_enabled,
        message,
    )
}

/// Insert content at a destination symbol or container.
/// Used by both Move and Copy operations to avoid duplication.
#[allow(clippy::too_many_arguments)]
fn insert_at_destination(
    editor: &edit::Editor,
    file_path: &Path,
    content: &str,
    original_content: &str,
    matches: &[edit::SymbolLocation],
    destination: &str,
    at: Position,
    case_insensitive: bool,
) -> Result<String, String> {
    let mut result = content.to_string();

    // Insert at destination, order depends on position type:
    // - append: original order [first..last] → iterate reversed matches
    // - others: reverse order [last..first] → iterate matches as-is
    let iter: Box<dyn Iterator<Item = _>> = if matches!(at, Position::Append) {
        Box::new(matches.iter().rev())
    } else {
        Box::new(matches.iter())
    };

    for loc in iter {
        let source_content = &original_content[loc.start_byte..loc.end_byte];
        result = match at {
            Position::Before | Position::After => {
                let dest_loc = editor
                    .find_symbol(file_path, &result, destination, case_insensitive)
                    .ok_or_else(|| format!("Destination not found: {}", destination))?;
                if matches!(at, Position::Before) {
                    editor.insert_before(&result, &dest_loc, source_content)
                } else {
                    editor.insert_after(&result, &dest_loc, source_content)
                }
            }
            Position::Prepend | Position::Append => {
                let body = editor
                    .find_container_body(file_path, &result, destination)
                    .ok_or_else(|| format!("Container not found: {}", destination))?;
                if matches!(at, Position::Prepend) {
                    editor.prepend_to_container(&result, &body, source_content)
                } else {
                    editor.append_to_container(&result, &body, source_content)
                }
            }
        };
    }

    Ok(result)
}

/// Insert source content at a destination symbol by position.
/// For single-symbol operations in do_edit.
fn insert_single_at_destination(
    editor: &edit::Editor,
    file_path: &std::path::Path,
    content: &str,
    source_content: &str,
    destination: &str,
    at: Position,
    case_insensitive: bool,
) -> Result<String, String> {
    match at {
        Position::Before | Position::After => {
            let dest_loc = editor
                .find_symbol(file_path, content, destination, case_insensitive)
                .ok_or_else(|| format!("Destination not found: {}", destination))?;
            Ok(if matches!(at, Position::Before) {
                editor.insert_before(content, &dest_loc, source_content)
            } else {
                editor.insert_after(content, &dest_loc, source_content)
            })
        }
        Position::Prepend | Position::Append => {
            let body = editor
                .find_container_body(file_path, content, destination)
                .ok_or_else(|| format!("Container not found: {}", destination))?;
            Ok(if matches!(at, Position::Prepend) {
                editor.prepend_to_container(content, &body, source_content)
            } else {
                editor.append_to_container(content, &body, source_content)
            })
        }
    }
}

/// Handle glob pattern edits (multi-symbol operations).
#[allow(clippy::too_many_arguments)]
fn handle_glob_edit(
    pattern: &str,
    action: EditAction,
    editor: &edit::Editor,
    content: &str,
    file_path: &Path,
    rel_path: &str,
    dry_run: bool,
    multiple: bool,
    root: &Path,
    shadow_enabled: bool,
    message: Option<&str>,
    case_insensitive: bool,
) -> Result<EditOutput, String> {
    let matches = editor.find_symbols_matching(file_path, content, pattern);

    if matches.is_empty() {
        return Err(format!("No symbols match pattern: {}", pattern));
    }

    let count = matches.len();

    if count > 1 && !multiple {
        let names: Vec<&str> = matches.iter().map(|m| m.name.as_str()).collect();
        return Err(format!(
            "Pattern '{}' matches {} symbols. Use --multiple to confirm.\nMatched: {}",
            pattern,
            count,
            names.join(", ")
        ));
    }
    let names: Vec<String> = matches.iter().map(|m| m.name.clone()).collect();

    // Matches are sorted in reverse order (highest byte offset first) for safe deletion
    let (operation, new_content) = match action {
        EditAction::Delete => {
            let mut result = content.to_string();
            for loc in &matches {
                result = editor.delete_symbol(&result, loc);
            }
            ("delete", result)
        }

        EditAction::Replace {
            content: ref new_code,
        } => {
            let mut result = content.to_string();
            for loc in &matches {
                result = editor.replace_symbol(&result, loc, new_code);
            }
            ("replace", result)
        }

        EditAction::Insert {
            content: ref insert_content,
            at,
        } => {
            let mut result = content.to_string();
            for loc in &matches {
                result = match at {
                    Position::Before => editor.insert_before(&result, loc, insert_content),
                    Position::After => editor.insert_after(&result, loc, insert_content),
                    Position::Prepend | Position::Append => {
                        let body = editor
                            .find_container_body(file_path, &result, &loc.name)
                            .ok_or_else(|| format!("'{}' is not a container", loc.name))?;
                        if matches!(at, Position::Prepend) {
                            editor.prepend_to_container(&result, &body, insert_content)
                        } else {
                            editor.append_to_container(&result, &body, insert_content)
                        }
                    }
                };
            }
            (position_op_name("insert", at), result)
        }

        EditAction::Move {
            ref destination,
            at,
        } => {
            let mut result = content.to_string();
            for loc in &matches {
                result = editor.delete_symbol(&result, loc);
            }
            let new_content = insert_at_destination(
                editor,
                file_path,
                &result,
                content,
                &matches,
                destination,
                at,
                case_insensitive,
            )?;
            (position_op_name("move", at), new_content)
        }

        EditAction::Copy {
            ref destination,
            at,
        } => {
            let new_content = insert_at_destination(
                editor,
                file_path,
                content,
                content,
                &matches,
                destination,
                at,
                case_insensitive,
            )?;
            (position_op_name("copy", at), new_content)
        }

        EditAction::Swap { .. } => {
            return Err(format!(
                "'swap' is not supported with glob patterns (ambiguous pairing). Matched: {}",
                names.join(", ")
            ));
        }
    };

    apply_edit(
        dry_run,
        rel_path,
        Some(pattern),
        operation,
        &new_content,
        file_path,
        root,
        shadow_enabled,
        message,
    )
}

/// Perform structural edits on a file.
#[allow(clippy::too_many_arguments)]
fn do_edit(
    target: &str,
    action: EditAction,
    root: Option<&Path>,
    dry_run: bool,
    yes: bool,
    exclude: &[String],
    only: &[String],
    multiple: bool,
    message: Option<&str>,
    case_insensitive: bool,
) -> Result<EditOutput, String> {
    use crate::commands::edit::Operation;

    let root = root
        .map(|p| p.to_path_buf())
        // normalize-syntax-allow: rust/unwrap-in-impl - current_dir() only fails if cwd was deleted (OS-level failure)
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let config = NormalizeConfig::load(&root);
    let shadow_enabled = config.shadow.enabled();

    if matches!(action, EditAction::Delete)
        && !yes
        && !dry_run
        && config.shadow.warn_on_delete.unwrap_or(true)
    {
        return Err(
            "Delete requires confirmation. Use --yes or -y to confirm.\n\
             To disable this warning: set [shadow] warn_on_delete = false in config"
                .to_string(),
        );
    }

    let unified = path_resolve::resolve_unified(target, &root)
        .ok_or_else(|| format!("No matches for: {}", target))?;

    if unified.is_directory {
        return Err(format!("Cannot edit a directory: {}", target));
    }

    if crate::commands::build_filter(&root, exclude, only)
        .is_some_and(|f| !f.matches(Path::new(&unified.file_path)))
    {
        return Err(format!(
            "Target '{}' excluded by filter (resolved to {})",
            target, unified.file_path
        ));
    }

    let file_path = root.join(&unified.file_path);
    let content =
        std::fs::read_to_string(&file_path).map_err(|e| format!("Error reading file: {}", e))?;

    let editor = edit::Editor::new();

    if unified.symbol_path.is_empty() {
        return handle_file_level(
            &action,
            &editor,
            &content,
            &file_path,
            &unified.file_path,
            dry_run,
            &root,
            shadow_enabled,
            message,
        );
    }

    let symbol_pattern = unified.symbol_path.join("/");

    if edit::Editor::is_glob_pattern(&symbol_pattern) {
        return handle_glob_edit(
            &symbol_pattern,
            action,
            &editor,
            &content,
            &file_path,
            &unified.file_path,
            dry_run,
            multiple,
            &root,
            shadow_enabled,
            message,
            case_insensitive,
        );
    }

    // normalize-syntax-allow: rust/unwrap-in-impl - symbol_path is non-empty (checked via is_empty() + early return above)
    let symbol_name = unified.symbol_path.last().unwrap();
    let loc = editor
        .find_symbol(&file_path, &content, symbol_name, case_insensitive)
        .ok_or_else(|| format!("Symbol not found: {}", symbol_name))?;

    let (operation, new_content) = match action {
        EditAction::Delete => (Operation::Delete, editor.delete_symbol(&content, &loc)),

        EditAction::Replace {
            content: ref new_code,
        } => (
            Operation::Replace,
            editor.replace_symbol(&content, &loc, new_code),
        ),

        EditAction::Swap { ref other } => {
            let other_loc = editor
                .find_symbol(&file_path, &content, other, case_insensitive)
                .ok_or_else(|| format!("Other symbol not found: {}", other))?;
            let (first_loc, second_loc) = if loc.start_byte < other_loc.start_byte {
                (&loc, &other_loc)
            } else {
                (&other_loc, &loc)
            };
            let first_content = content[first_loc.start_byte..first_loc.end_byte].to_string();
            let second_content = content[second_loc.start_byte..second_loc.end_byte].to_string();
            let mut new = content.clone();
            new.replace_range(second_loc.start_byte..second_loc.end_byte, &first_content);
            new.replace_range(first_loc.start_byte..first_loc.end_byte, &second_content);
            (Operation::Swap, new)
        }

        EditAction::Insert {
            content: ref insert_content,
            at,
        } => {
            let result = match at {
                Position::Before => editor.insert_before(&content, &loc, insert_content),
                Position::After => editor.insert_after(&content, &loc, insert_content),
                Position::Prepend | Position::Append => {
                    let body = editor
                        .find_container_body(&file_path, &content, symbol_name)
                        .ok_or_else(|| format!("Error: '{}' is not a container", symbol_name))?;
                    if matches!(at, Position::Prepend) {
                        editor.prepend_to_container(&content, &body, insert_content)
                    } else {
                        editor.append_to_container(&content, &body, insert_content)
                    }
                }
            };
            (Operation::Insert(at), result)
        }

        EditAction::Move {
            ref destination,
            at,
        } => {
            let source_content = content[loc.start_byte..loc.end_byte].to_string();
            let without_source = editor.delete_symbol(&content, &loc);
            let result = insert_single_at_destination(
                &editor,
                &file_path,
                &without_source,
                &source_content,
                destination,
                at,
                case_insensitive,
            )?;
            (Operation::Move(at), result)
        }

        EditAction::Copy {
            ref destination,
            at,
        } => {
            let source_content = &content[loc.start_byte..loc.end_byte];
            let result = insert_single_at_destination(
                &editor,
                &file_path,
                &content,
                source_content,
                destination,
                at,
                case_insensitive,
            )?;
            (Operation::Copy(at), result)
        }
    };

    apply_edit(
        dry_run,
        &unified.file_path,
        Some(symbol_name),
        &operation.to_string(),
        &new_content,
        &file_path,
        &root,
        shadow_enabled,
        message,
    )
}

/// Apply the same structural edit to a named symbol across all files matching a filter.
/// Skips files where the symbol is not found. Returns the list of modified files.
#[allow(clippy::too_many_arguments)]
fn do_edit_each(
    symbol: &str,
    action: EditAction,
    root: Option<&Path>,
    dry_run: bool,
    exclude: &[String],
    only: &[String],
    message: Option<&str>,
    case_insensitive: bool,
) -> Result<EditResult, String> {
    let root = root
        .map(|p| p.to_path_buf())
        // normalize-syntax-allow: rust/unwrap-in-impl - current_dir() only fails if cwd was deleted (OS-level failure)
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let op_name = match &action {
        EditAction::Insert { .. } => "insert_each",
        EditAction::Delete => "delete_each",
        EditAction::Replace { .. } => "replace_each",
        _ => "edit_each",
    };

    let config = NormalizeConfig::load(&root);
    let shadow_enabled = config.shadow.enabled();

    let filter = crate::commands::build_filter(&root, exclude, only);
    let all = path_resolve::all_files(&root);
    let editor = edit::Editor::new();

    let mut modified: Vec<String> = vec![];
    let mut _skipped = 0usize;
    let mut errors: Vec<String> = vec![];

    // Collect files to edit first (so shadow gets all paths at once)
    let candidates: Vec<_> = all
        .iter()
        .filter(|m| m.kind == normalize_path_resolve::PathMatchKind::File)
        .filter(|m| {
            filter
                .as_ref()
                .is_none_or(|f| f.matches(Path::new(&m.path)))
        })
        .collect();

    // Shadow: snapshot before any writes
    if !dry_run && shadow_enabled {
        let paths: Vec<_> = candidates.iter().map(|m| root.join(&m.path)).collect();
        let shadow = Shadow::new(&root);
        if let Err(e) = shadow.before_edit(&paths.iter().map(|p| p.as_path()).collect::<Vec<_>>()) {
            eprintln!("warning: shadow git: {}", e);
        }
    }

    for m in &candidates {
        let file_path = root.join(&m.path);
        let content = match std::fs::read_to_string(&file_path) {
            Ok(c) => c,
            Err(_) => {
                _skipped += 1;
                continue;
            }
        };

        let loc = match editor.find_symbol(&file_path, &content, symbol, case_insensitive) {
            Some(l) => l,
            None => {
                _skipped += 1;
                continue;
            }
        };

        let new_content = match &action {
            EditAction::Insert {
                content: insert_content,
                at,
            } => match at {
                Position::Before => editor.insert_before(&content, &loc, insert_content),
                Position::After => editor.insert_after(&content, &loc, insert_content),
                Position::Prepend => {
                    match editor.find_container_body(&file_path, &content, symbol) {
                        Some(body) => editor.prepend_to_container(&content, &body, insert_content),
                        None => {
                            errors.push(format!("{}: no container body for prepend", m.path));
                            continue;
                        }
                    }
                }
                Position::Append => {
                    match editor.find_container_body(&file_path, &content, symbol) {
                        Some(body) => editor.append_to_container(&content, &body, insert_content),
                        None => {
                            errors.push(format!("{}: no container body for append", m.path));
                            continue;
                        }
                    }
                }
            },
            EditAction::Delete => editor.delete_symbol(&content, &loc),
            EditAction::Replace { content: new_code } => {
                editor.replace_symbol(&content, &loc, new_code)
            }
            _ => {
                errors.push(format!("{}: action not supported in --each mode", m.path));
                continue;
            }
        };

        if dry_run {
            modified.push(m.path.clone());
            continue;
        }

        match std::fs::write(&file_path, &new_content) {
            Ok(_) => modified.push(m.path.clone()),
            Err(e) => errors.push(format!("{}: {}", m.path, e)),
        }
    }

    // Shadow: commit after all writes
    if !dry_run && shadow_enabled && !modified.is_empty() {
        let paths: Vec<_> = modified.iter().map(|p| root.join(p)).collect();
        let shadow = Shadow::new(&root);
        let info = EditInfo {
            operation: op_name.to_string(),
            target: symbol.to_string(),
            files: paths.clone(),
            message: message.map(String::from),
            workflow: None,
        };
        if let Err(e) = shadow.after_edit(&info) {
            eprintln!("warning: shadow git: {}", e);
        }
    }

    if !errors.is_empty() {
        eprintln!("Errors during --each edit:");
        for e in &errors {
            eprintln!("  {}", e);
        }
    }

    Ok(EditResult {
        success: errors.is_empty(),
        operation: op_name.to_string(),
        file: None,
        symbol: Some(symbol.to_string()),
        dry_run,
        new_content: None,
        changes: vec![],
        files: modified,
    })
}

/// Handle undo/redo/goto operations on shadow git history.
#[allow(clippy::too_many_arguments)]
fn do_undo_redo(
    root: Option<&Path>,
    undo: Option<usize>,
    redo: bool,
    goto: Option<&str>,
    file_filter: Option<&str>,
    cross_checkpoint: bool,
    dry_run: bool,
    force: bool,
) -> Result<UndoOutput, String> {
    let root = root
        .map(|p| p.to_path_buf())
        // normalize-syntax-allow: rust/unwrap-in-impl - current_dir() only fails if cwd was deleted (OS-level failure)
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let shadow = Shadow::new(&root);

    if !shadow.exists() {
        return Err(
            "No shadow history exists. Make an edit first with `normalize edit`.".to_string(),
        );
    }

    if let Some(ref_str) = goto {
        let result = shadow
            .goto(ref_str, dry_run, force)
            .map_err(|e| e.to_string())?;
        return Ok(UndoOutput {
            operation: if dry_run {
                "goto_preview".to_string()
            } else {
                "goto".to_string()
            },
            dry_run,
            changes: vec![UndoChange {
                description: result.description,
                commit: result.undone_commit,
                files: result
                    .files
                    .iter()
                    .map(|p| p.display().to_string())
                    .collect(),
                conflicts: vec![],
            }],
        });
    }

    if redo {
        let result = shadow.redo().map_err(|e| e.to_string())?;
        return Ok(UndoOutput {
            operation: "redo".to_string(),
            dry_run: false,
            changes: vec![UndoChange {
                description: result.description,
                commit: result.undone_commit,
                files: result
                    .files
                    .iter()
                    .map(|p| p.display().to_string())
                    .collect(),
                conflicts: vec![],
            }],
        });
    }

    if let Some(count) = undo {
        let count = if count == 0 { 1 } else { count };
        let results = shadow
            .undo(count, file_filter, cross_checkpoint, dry_run, force)
            .map_err(|e| e.to_string())?;
        let changes = results
            .into_iter()
            .map(|r| UndoChange {
                description: r.description,
                commit: r.undone_commit,
                files: r.files.iter().map(|p| p.display().to_string()).collect(),
                conflicts: r.conflicts,
            })
            .collect();
        return Ok(UndoOutput {
            operation: if dry_run {
                "undo_preview".to_string()
            } else {
                "undo".to_string()
            },
            dry_run,
            changes,
        });
    }

    Err("No undo or redo operation specified".to_string())
}

/// Apply batch edits from a JSON file.
fn do_batch_edit(
    batch_file: &str,
    root: Option<&Path>,
    dry_run: bool,
    message: Option<&str>,
) -> Result<BatchOutput, String> {
    let root = root
        .map(|p| p.to_path_buf())
        // normalize-syntax-allow: rust/unwrap-in-impl - current_dir() only fails if cwd was deleted (OS-level failure)
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let json_content = if batch_file == "-" {
        use std::io::Read;
        let mut buf = String::new();
        std::io::stdin()
            .read_to_string(&mut buf)
            .map_err(|_| "Failed to read from stdin".to_string())?;
        buf
    } else {
        std::fs::read_to_string(batch_file)
            .map_err(|e| format!("Failed to read {}: {}", batch_file, e))?
    };

    let batch = edit::BatchEdit::from_json(&json_content)
        .map_err(|e| format!("Failed to parse batch edits: {}", e))?;

    let batch = if let Some(msg) = message {
        batch.with_message(msg)
    } else {
        batch
    };

    if dry_run {
        let preview = batch
            .preview(&root)
            .map_err(|e| format!("Dry run failed: {}", e))?;
        let files_modified = preview
            .files
            .iter()
            .map(|f| f.path.display().to_string())
            .collect();
        return Ok(BatchOutput {
            dry_run: true,
            files_modified,
        });
    }

    let result = batch
        .apply(&root)
        .map_err(|e| format!("Batch edit failed: {}", e))?;

    let config = NormalizeConfig::load(&root);
    if config.shadow.enabled() {
        let shadow = Shadow::new(&root);
        if shadow.exists() {
            let file_refs: Vec<&Path> = result.files_modified.iter().map(|p| p.as_path()).collect();
            let _ = shadow.before_edit(&file_refs);
            let edit_info = EditInfo {
                operation: "batch".to_string(),
                target: format!("{} files", result.files_modified.len()),
                files: result.files_modified.clone(),
                message: message.map(|s| s.to_string()),
                workflow: None,
            };
            let _ = shadow.after_edit(&edit_info);
        }
    }

    Ok(BatchOutput {
        dry_run: false,
        files_modified: result
            .files_modified
            .iter()
            .map(|p| p.to_string_lossy().into_owned())
            .collect(),
    })
}

// ── Edit sub-service ──────────────────────────────────────────────────

/// Edit sub-service.
pub struct EditService {
    pub(crate) history: HistoryService,
}

impl EditService {
    fn display_move(&self, report: &MoveReport) -> String {
        report.format_text()
    }

    fn display_introduce_variable(&self, report: &IntroduceVariableReport) -> String {
        report.format_text()
    }

    fn display_inline_variable(&self, report: &InlineVariableReport) -> String {
        report.format_text()
    }

    fn display_add_parameter(&self, report: &AddParameterReport) -> String {
        report.format_text()
    }

    fn display_inline_function(&self, report: &InlineFunctionReport) -> String {
        report.format_text()
    }

    fn display_extract_function(&self, report: &ExtractFunctionReport) -> String {
        report.format_text()
    }
}

#[cli(
    name = "edit",
    description = "Edit code by symbol name. Use for batch renames, signature changes, or pattern-based rewrites."
)]
impl EditService {
    /// Delete a symbol.
    ///
    /// With `--each`, deletes the symbol from every file matching `--only` that contains it —
    /// useful for removing a trait method from all implementations at once.
    ///
    /// Examples:
    ///   normalize edit delete src/lib.rs/my_function              # delete a function
    ///   normalize edit delete src/lib.rs/my_function --dry-run    # preview the deletion
    ///   normalize edit delete "*/old_method" --each --only "*.rs" # delete from all matching files
    #[allow(clippy::too_many_arguments)]
    pub fn delete(
        &self,
        #[param(positional, help = "Target to edit (path/Symbol)")] target: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(short = 'y', help = "Skip confirmation")] yes: bool,
        #[param(help = "Exclude files matching patterns")] exclude: Vec<String>,
        #[param(help = "Only include files matching patterns")] only: Vec<String>,
        #[param(help = "Allow glob patterns to match multiple symbols")] multiple: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'i', help = "Case-insensitive matching")] case_insensitive: bool,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
        #[param(help = "Delete from every file matching --only that contains the symbol")]
        each: bool,
    ) -> Result<EditResult, String> {
        if each {
            let root_path = root.as_deref().map(std::path::Path::new);
            return do_edit_each(
                &target,
                EditAction::Delete,
                root_path,
                dry_run,
                &exclude,
                &only,
                message.as_deref(),
                case_insensitive,
            );
        }
        do_edit(
            &target,
            EditAction::Delete,
            root.as_deref().map(Path::new),
            dry_run,
            yes,
            &exclude,
            &only,
            multiple,
            message.as_deref(),
            case_insensitive,
        )
        .map(|out| EditResult {
            success: true,
            operation: out.operation,
            file: Some(out.file),
            symbol: out.symbol,
            dry_run: out.dry_run,
            new_content: out.new_content,
            changes: vec![],
            files: vec![],
        })
    }

    /// Replace a symbol with new content.
    ///
    /// With `--each`, replaces the symbol in every file matching `--only` that contains it —
    /// useful for updating a trait method body across all implementations at once.
    ///
    /// Examples:
    ///   normalize edit replace src/lib.rs/old_fn --content "fn old_fn() { todo!() }"
    ///   normalize edit replace src/lib.rs/old_fn --content "..." --dry-run
    #[allow(clippy::too_many_arguments)]
    pub fn replace(
        &self,
        #[param(positional, help = "Target to edit (path/Symbol)")] target: String,
        #[param(help = "Replacement content")] content: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(help = "Exclude files matching patterns")] exclude: Vec<String>,
        #[param(help = "Only include files matching patterns")] only: Vec<String>,
        #[param(help = "Allow glob patterns to match multiple symbols")] multiple: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'i', help = "Case-insensitive matching")] case_insensitive: bool,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
        #[param(help = "Replace in every file matching --only that contains the symbol")]
        each: bool,
    ) -> Result<EditResult, String> {
        if each {
            let root_path = root.as_deref().map(std::path::Path::new);
            return do_edit_each(
                &target,
                EditAction::Replace { content },
                root_path,
                dry_run,
                &exclude,
                &only,
                message.as_deref(),
                case_insensitive,
            );
        }
        do_edit(
            &target,
            EditAction::Replace { content },
            root.as_deref().map(Path::new),
            dry_run,
            false,
            &exclude,
            &only,
            multiple,
            message.as_deref(),
            case_insensitive,
        )
        .map(|out| EditResult {
            success: true,
            operation: out.operation,
            file: Some(out.file),
            symbol: out.symbol,
            dry_run: out.dry_run,
            new_content: out.new_content,
            changes: vec![],
            files: vec![],
        })
    }

    /// Swap two symbols
    ///
    /// Examples:
    ///   normalize edit swap src/lib.rs/fn_a fn_b             # swap two functions in the same file
    ///   normalize edit swap src/lib.rs/fn_a fn_b --dry-run   # preview the swap
    #[allow(clippy::too_many_arguments)]
    pub fn swap(
        &self,
        #[param(positional, help = "Target to edit (path/Symbol)")] target: String,
        #[param(positional, help = "Symbol to swap with")] other: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(help = "Exclude files matching patterns")] exclude: Vec<String>,
        #[param(help = "Only include files matching patterns")] only: Vec<String>,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'i', help = "Case-insensitive matching")] case_insensitive: bool,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<EditResult, String> {
        do_edit(
            &target,
            EditAction::Swap { other },
            root.as_deref().map(Path::new),
            dry_run,
            false,
            &exclude,
            &only,
            false,
            message.as_deref(),
            case_insensitive,
        )
        .map(|out| EditResult {
            success: true,
            operation: out.operation,
            file: Some(out.file),
            symbol: out.symbol,
            dry_run: out.dry_run,
            new_content: out.new_content,
            changes: vec![],
            files: vec![],
        })
    }

    /// Insert content relative to a symbol.
    ///
    /// With `--each`, applies the insert to every file matching `--only` that contains
    /// the symbol — useful for adding a method to all implementations of a trait.
    ///
    /// Examples:
    ///   normalize edit insert src/lib.rs/MyStruct --content "fn new() {}" --at after
    ///   normalize edit insert src/lib.rs/MyStruct --content "use foo;" --at before --dry-run
    #[allow(clippy::too_many_arguments)]
    pub fn insert(
        &self,
        #[param(positional, help = "Target symbol")] target: String,
        #[param(help = "Content to insert")] content: String,
        #[param(help = "Position: before, after, prepend, append")] at: Position,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(help = "Exclude files matching patterns")] exclude: Vec<String>,
        #[param(help = "Only include files matching patterns")] only: Vec<String>,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'i', help = "Case-insensitive matching")] case_insensitive: bool,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
        #[param(help = "Apply to every file matching --only that contains the symbol")] each: bool,
    ) -> Result<EditResult, String> {
        if each {
            let root_path = root.as_deref().map(std::path::Path::new);
            return do_edit_each(
                &target,
                EditAction::Insert { content, at },
                root_path,
                dry_run,
                &exclude,
                &only,
                message.as_deref(),
                case_insensitive,
            );
        }
        do_edit(
            &target,
            EditAction::Insert { content, at },
            root.as_deref().map(Path::new),
            dry_run,
            false,
            &exclude,
            &only,
            false,
            message.as_deref(),
            case_insensitive,
        )
        .map(|out| EditResult {
            success: true,
            operation: out.operation,
            file: Some(out.file),
            symbol: out.symbol,
            dry_run: out.dry_run,
            new_content: out.new_content,
            changes: vec![],
            files: vec![],
        })
    }

    /// Rename a symbol across its definition, call sites, and import statements.
    ///
    /// Uses the facts index to find cross-file references. If the index is unavailable,
    /// falls back to renaming only the definition.
    ///
    /// Aborts if the new name already exists as a symbol in the definition file or as
    /// an import in any affected file. Use `--force` to override conflict checks.
    ///
    /// Examples:
    ///   normalize edit rename src/lib.rs/old_name new_name             # rename across all references
    ///   normalize edit rename src/lib.rs/old_name new_name --dry-run   # preview rename changes
    ///   normalize edit rename src/lib.rs/old_name new_name --force     # ignore name conflicts
    pub async fn rename(
        &self,
        #[param(positional, help = "Target symbol (path/Symbol)")] target: String,
        #[param(positional, help = "New name for the symbol")] new_name: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(help = "Proceed even when name conflicts are detected")] force: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<EditResult, String> {
        let root_path = root.as_deref().map(std::path::Path::new);
        do_rename(
            &target,
            &new_name,
            root_path,
            dry_run,
            force,
            message.as_deref(),
        )
        .await
    }

    /// Undo the last N edits
    ///
    /// Examples:
    ///   normalize edit undo                          # undo the last edit
    ///   normalize edit undo 3                        # undo the last 3 edits
    ///   normalize edit undo --file src/lib.rs        # undo edits only for a specific file
    ///   normalize edit undo --dry-run                # preview what would be undone
    pub fn undo(
        &self,
        #[param(positional, help = "Number of edits to undo (default: 1)")] count: Option<usize>,
        #[param(help = "Undo changes only for specific file")] file: Option<String>,
        #[param(help = "Allow undo to cross git commit boundaries")] cross_checkpoint: bool,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(help = "Force undo even if files were modified externally")] force: bool,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<EditResult, String> {
        do_undo_redo(
            root.as_deref().map(Path::new),
            Some(count.unwrap_or(1)),
            false,
            None,
            file.as_deref(),
            cross_checkpoint,
            dry_run,
            force,
        )
        .map(|out| EditResult {
            success: true,
            operation: out.operation,
            file: None,
            symbol: None,
            dry_run: out.dry_run,
            new_content: None,
            changes: out
                .changes
                .into_iter()
                .map(|c| EditChange {
                    description: c.description,
                    commit: c.commit,
                    files: c.files,
                    conflicts: c.conflicts,
                })
                .collect(),
            files: vec![],
        })
    }

    /// Redo the last undone edit
    ///
    /// Examples:
    ///   normalize edit redo                          # redo the last undone edit
    pub fn redo(
        &self,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<EditResult, String> {
        do_undo_redo(
            root.as_deref().map(Path::new),
            None,
            true,
            None,
            None,
            false,
            false,
            false,
        )
        .map(|out| EditResult {
            success: true,
            operation: out.operation,
            file: None,
            symbol: None,
            dry_run: out.dry_run,
            new_content: None,
            changes: out
                .changes
                .into_iter()
                .map(|c| EditChange {
                    description: c.description,
                    commit: c.commit,
                    files: c.files,
                    conflicts: c.conflicts,
                })
                .collect(),
            files: vec![],
        })
    }

    /// Jump to a specific shadow commit
    ///
    /// Examples:
    ///   normalize edit goto abc1234                  # restore files to a specific shadow commit
    ///   normalize edit goto abc1234 --dry-run        # preview what would change
    pub fn goto(
        &self,
        #[param(positional, help = "Shadow commit reference")] commit_ref: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(help = "Force even if files were modified externally")] force: bool,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<EditResult, String> {
        do_undo_redo(
            root.as_deref().map(Path::new),
            None,
            false,
            Some(&commit_ref),
            None,
            false,
            dry_run,
            force,
        )
        .map(|out| EditResult {
            success: true,
            operation: out.operation,
            file: None,
            symbol: None,
            dry_run: out.dry_run,
            new_content: None,
            changes: out
                .changes
                .into_iter()
                .map(|c| EditChange {
                    description: c.description,
                    commit: c.commit,
                    files: c.files,
                    conflicts: c.conflicts,
                })
                .collect(),
            files: vec![],
        })
    }

    /// Apply batch edits from JSON file
    ///
    /// Examples:
    ///   normalize edit batch edits.json              # apply edits from a JSON file
    ///   normalize edit batch - --dry-run             # read edits from stdin, preview only
    pub fn batch(
        &self,
        #[param(positional, help = "JSON file with edits (or - for stdin)")] file: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<EditResult, String> {
        do_batch_edit(
            &file,
            root.as_deref().map(Path::new),
            dry_run,
            message.as_deref(),
        )
        .map(|out| EditResult {
            success: true,
            operation: "batch".to_string(),
            file: None,
            symbol: None,
            dry_run: out.dry_run,
            new_content: None,
            changes: vec![],
            files: out.files_modified,
        })
    }

    /// Move a symbol's definition to another file and rewrite import sites.
    ///
    /// Locates the symbol via the facts index (or in-place extraction when no index
    /// is available), appends its definition to the destination file, removes it from
    /// the source, and rewrites import statements in every file that imported it.
    ///
    /// Per-language import rewriting is best-effort: where the new module path can be
    /// derived (Python, Go, JS/TS), import sites are updated. Where it can't (Rust
    /// `use crate::…`, JS module-resolution overrides), affected sites are reported
    /// as skipped and a warning is emitted — `move` will not fabricate wrong paths.
    ///
    /// Examples:
    ///   normalize edit move src/foo.py/MyClass src/lib/foo.py
    ///   normalize edit move crates/x/src/foo.rs/MyStruct crates/y/src/lib.rs --dry-run
    ///   normalize edit move src/foo.py/MyClass src/lib/foo.py --reexport
    #[cli(name = "move", display_with = "display_move")]
    #[allow(clippy::too_many_arguments)]
    pub async fn move_item(
        &self,
        #[param(positional, help = "Target symbol (path/Symbol)")] target: String,
        #[param(positional, help = "Destination file path")] destination: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(help = "Leave a re-export at the source location")] reexport: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<MoveReport, String> {
        do_move(
            &target,
            &destination,
            root.as_deref().map(Path::new),
            dry_run,
            reexport,
            message.as_deref(),
        )
        .await
    }

    /// Extract an expression into a named variable binding.
    ///
    /// Parses the file with tree-sitter to locate the expression at the given range,
    /// inserts a binding declaration (`let name = <expr>;` or language equivalent)
    /// immediately before the containing statement, and replaces the expression with
    /// the variable name. Supports Rust, TypeScript, JavaScript, Python and any other
    /// language with a tree-sitter grammar available.
    ///
    /// The range is specified as `start_line:start_col-end_line:end_col` (1-based).
    ///
    /// Examples:
    ///   normalize edit introduce-variable src/lib.rs 10:20-10:35 value
    ///   normalize edit introduce-variable src/main.py 5:5-5:18 result --dry-run
    #[cli(
        name = "introduce-variable",
        display_with = "display_introduce_variable"
    )]
    pub fn introduce_variable(
        &self,
        #[param(positional, help = "File to edit")] file: String,
        #[param(
            positional,
            help = "Expression range as start_line:start_col-end_line:end_col (1-based)"
        )]
        range: String,
        #[param(positional, help = "Variable name to introduce")] name: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<IntroduceVariableReport, String> {
        do_introduce_variable(
            &file,
            &range,
            &name,
            root.as_deref().map(Path::new),
            dry_run,
            message.as_deref(),
        )
    }

    /// Inline a variable: replace all uses with the initializer and remove the binding.
    ///
    /// Parses the file with tree-sitter to locate the variable declaration at the given
    /// position, finds all references within the same scope, replaces each reference with
    /// the initializer expression, and removes the declaration line.
    ///
    /// The position must point to the variable name in its declaration (e.g. the `x` in
    /// `let x = expr`). Supports Rust (`let`), TypeScript/JavaScript (`const`/`let`/`var`),
    /// and Python (bare assignment).
    ///
    /// Errors if the variable has no initializer or is reassigned anywhere in the scope.
    /// Warns if the initializer has side effects and is used more than once.
    ///
    /// Examples:
    ///   normalize edit inline-variable src/lib.rs 10:9
    ///   normalize edit inline-variable src/main.py 5:5 --dry-run
    #[cli(name = "inline-variable", display_with = "display_inline_variable")]
    pub fn inline_variable(
        &self,
        #[param(positional, help = "File to edit")] file: String,
        #[param(
            positional,
            help = "Position of the variable name in its declaration as line:col (1-based)"
        )]
        position: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(help = "Refuse to inline if variable has 0 uses")] safe: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<InlineVariableReport, String> {
        do_inline_variable(
            &file,
            &position,
            dry_run,
            safe,
            root.as_deref().map(Path::new),
            message.as_deref(),
        )
    }

    /// Add a parameter to a function signature and update all call sites.
    ///
    /// Parses the file with tree-sitter to locate the function, inserts the new parameter
    /// into the signature at the given position (default: last), then finds every call site
    /// via the facts index and inserts the default value at the same argument position.
    ///
    /// Supports Rust, TypeScript/JavaScript, and Python. Requires `--type` for typed
    /// languages if you want a type annotation on the new parameter.
    ///
    /// If the facts index is unavailable, only the definition is updated and a warning is
    /// printed. Run `normalize structure rebuild` first to enable cross-file updates.
    ///
    /// Examples:
    ///   normalize edit add-parameter src/lib.rs my_func --param ctx --type Context --default Context::default()
    ///   normalize edit add-parameter src/main.py helper --param verbose --default False
    ///   normalize edit add-parameter src/lib.rs my_func --param x --type i32 --default 0 --position 0 --dry-run
    #[cli(name = "add-parameter", display_with = "display_add_parameter")]
    #[allow(clippy::too_many_arguments)]
    pub async fn add_parameter(
        &self,
        #[param(positional, help = "File containing the function definition")] file: String,
        #[param(positional, help = "Name of the function to modify")] function_name: String,
        #[param(help = "Name of the new parameter")] param: String,
        #[param(help = "Default value to insert at call sites")] default: String,
        #[param(help = "Type annotation for typed languages (Rust, TypeScript)")] r#type: Option<
            String,
        >,
        #[param(help = "0-based position to insert at (default: last)")] position: Option<usize>,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<AddParameterReport, String> {
        do_add_parameter(
            &file,
            &function_name,
            &param,
            &default,
            r#type.as_deref(),
            position,
            root.as_deref().map(Path::new),
            dry_run,
            message.as_deref(),
        )
        .await
    }

    /// Inline a single-use function at its call site.
    ///
    /// Locates the function definition and its sole call site within the same file,
    /// substitutes arguments for parameters, replaces the call with the function body,
    /// and removes the now-dead definition.
    ///
    /// Point `<file>` and `<line>:<col>` at the function name — either in the definition
    /// or at the call site.  Use `--force` to inline a function that is called more than once
    /// (all call sites are inlined).
    ///
    /// Examples:
    ///   normalize edit inline-function src/utils.ts 12:1           # inline function at line 12
    ///   normalize edit inline-function src/utils.ts 12:1 --dry-run # preview
    ///   normalize edit inline-function src/utils.ts 12:1 --force   # inline even if called >1×
    #[cli(name = "inline-function", display_with = "display_inline_function")]
    pub async fn inline_function(
        &self,
        #[param(positional, help = "File path")] file: String,
        #[param(positional, help = "Position in file (line:col, 1-based)")] position: String,
        #[param(help = "Dry run - show what would change")] dry_run: bool,
        #[param(help = "Inline even when the function is called more than once")] force: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<InlineFunctionReport, String> {
        do_inline_function(
            &file,
            &position,
            root.as_deref().map(Path::new),
            dry_run,
            force,
            message.as_deref(),
        )
        .await
    }

    /// Extract a block of code into a new function.
    ///
    /// Uses CFG liveness analysis to infer the correct parameters and return type.
    /// By default this is a dry-run; pass `--apply` to write the changes.
    ///
    /// Examples:
    ///   normalize edit extract-function src/lib.rs --lines 10-25 --name process_batch
    ///   normalize edit extract-function src/lib.rs --lines 10-25 --name process_batch --apply
    #[cli(name = "extract-function", display_with = "display_extract_function")]
    pub async fn extract_function(
        &self,
        #[param(positional, help = "File path")] file: String,
        #[param(help = "Line range to extract, e.g. 10-25 (1-based, inclusive)")] lines: String,
        #[param(help = "Name for the new function")] name: String,
        #[param(help = "Apply the changes (default: dry-run)")] apply: bool,
        #[param(short = 'm', help = "Message for shadow history")] message: Option<String>,
        #[param(short = 'r', help = "Root directory")] root: Option<String>,
    ) -> Result<ExtractFunctionReport, String> {
        do_extract_function(
            &file,
            &lines,
            &name,
            root.as_deref().map(Path::new),
            !apply,
            message.as_deref(),
        )
        .await
    }

    /// View shadow git edit history
    ///
    /// Examples:
    ///   normalize edit history list                  # show recent edit history
    ///   normalize edit history diff abc1234          # show diff for a shadow commit
    pub fn history(&self) -> &HistoryService {
        &self.history
    }
}

// ── Daemon notification ───────────────────────────────────────────────────────

/// After writing refactoring edits to disk, notify the daemon to re-index the
/// changed files immediately rather than waiting for inotify. Best-effort and
/// non-fatal: if the daemon is not running, this is a silent no-op.
fn notify_daemon_after_edit(root: &Path, modified_rel: &[String]) {
    if modified_rel.is_empty() {
        return;
    }
    let abs_paths: Vec<PathBuf> = modified_rel.iter().map(|rel| root.join(rel)).collect();
    let client = crate::daemon::DaemonClient::new();
    if let Err(e) = client.notify_files_changed(root, &abs_paths) {
        tracing::debug!("notify_daemon_after_edit: {}", e);
    }
}

// ── rename ────────────────────────────────────────────────────────────────────

/// Rename a symbol across its definition, all call sites, and all import statements.
///
/// Uses the facts index to find callers and importers. Falls back gracefully if the
/// index is not available (renames only the definition).
///
/// `target` is in `path/SymbolName` format (same as other edit commands).
/// Set `force` to proceed even when name conflicts are detected.
async fn do_rename(
    target: &str,
    new_name: &str,
    root: Option<&Path>,
    dry_run: bool,
    force: bool,
    message: Option<&str>,
) -> Result<EditResult, String> {
    let root = root
        .map(|p| p.to_path_buf())
        // normalize-syntax-allow: rust/unwrap-in-impl - current_dir() only fails if cwd was deleted (OS-level failure)
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let config = NormalizeConfig::load(&root);
    let shadow_enabled = config.shadow.enabled();

    // Build refactoring context
    let index = match crate::index::ensure_ready(&root).await {
        Ok(idx) => Some(idx),
        Err(e) => {
            eprintln!(
                "warning: index not available ({}); renaming definition only",
                e
            );
            None
        }
    };

    // Resolve path before entering refactoring engine
    let unified = path_resolve::resolve_unified(target, &root)
        .ok_or_else(|| format!("No matches for: {}", target))?;

    if unified.symbol_path.is_empty() {
        return Err(format!(
            "Target must include a symbol name (e.g. path/SymbolName), got: {}",
            target
        ));
    }

    let def_rel_path = unified.file_path.clone();
    // normalize-syntax-allow: rust/unwrap-in-impl - symbol_path is non-empty (checked above)
    let old_name = unified.symbol_path.last().unwrap().clone();

    let ctx = normalize_refactor::RefactoringContext {
        root: root.clone(),
        editor: edit::Editor::new(),
        index,
        loader: normalize_languages::GrammarLoader::new(),
    };

    // Plan
    let plan =
        normalize_refactor::rename::plan_rename(&ctx, &def_rel_path, &old_name, new_name, force)
            .await?;

    // Print warnings
    for warning in &plan.warnings {
        eprintln!("warning: {}", warning);
    }

    // Execute
    let executor = normalize_refactor::RefactoringExecutor {
        root: root.clone(),
        dry_run,
        shadow_enabled,
        message: message.map(String::from),
    };

    let modified = executor.apply(&plan)?;

    if !dry_run {
        notify_daemon_after_edit(&root, &modified);
    }

    Ok(EditResult {
        success: true,
        operation: "rename".to_string(),
        file: Some(def_rel_path),
        symbol: Some(old_name),
        dry_run,
        new_content: None,
        changes: vec![],
        files: modified,
    })
}

// ── introduce_variable ───────────────────────────────────────────────────────

/// Report returned by `normalize edit introduce-variable`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct IntroduceVariableReport {
    /// Relative file path that was (or would be) edited.
    pub file: String,
    /// The variable name that was introduced.
    pub name: String,
    /// 1-based line number where the binding was inserted.
    pub inserted_line: usize,
    /// Byte offset of the start of the replaced expression.
    pub replaced_start: usize,
    /// Byte offset of the end of the replaced expression.
    pub replaced_end: usize,
    /// Whether this was a dry run (no file written).
    pub dry_run: bool,
}

impl OutputFormatter for IntroduceVariableReport {
    fn format_text(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "Would introduce"
        } else {
            "Introduced"
        };
        let _ = writeln!(
            out,
            "{} variable '{}' at line {} in {}",
            verb, self.name, self.inserted_line, self.file
        );
        if self.dry_run {
            let _ = writeln!(out, "  (dry run — no files written)");
        }
        out
    }

    fn format_pretty(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "\x1b[1;33mWould introduce\x1b[0m"
        } else {
            "\x1b[1;32mIntroduced\x1b[0m"
        };
        let _ = writeln!(
            out,
            "{} variable '\x1b[1m{}\x1b[0m' at line \x1b[2m{}\x1b[0m in \x1b[2m{}\x1b[0m",
            verb, self.name, self.inserted_line, self.file
        );
        if self.dry_run {
            let _ = writeln!(out, "  \x1b[2m(dry run — no files written)\x1b[0m");
        }
        out
    }
}

fn do_introduce_variable(
    file: &str,
    range_str: &str,
    name: &str,
    root: Option<&Path>,
    dry_run: bool,
    message: Option<&str>,
) -> Result<IntroduceVariableReport, String> {
    let root: PathBuf = root
        .map(|p| p.to_path_buf())
        // normalize-syntax-allow: rust/unwrap-in-impl - current_dir() only fails if cwd deleted
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let config = NormalizeConfig::load(&root);
    let shadow_enabled = config.shadow.enabled();

    // Resolve file path (accept relative or absolute).
    let file_path = if Path::new(file).is_absolute() {
        PathBuf::from(file)
    } else {
        root.join(file)
    };

    let rel_path = file_path
        .strip_prefix(&root)
        .unwrap_or(&file_path)
        .to_string_lossy()
        .to_string();

    let content = std::fs::read_to_string(&file_path)
        .map_err(|e| format!("Error reading {}: {}", rel_path, e))?;

    // Parse range.
    let byte_range =
        normalize_refactor::introduce_variable::parse_line_col_range(&content, range_str)?;

    // Plan the refactoring.
    let outcome = normalize_refactor::introduce_variable::plan_introduce_variable(
        &file_path, &content, byte_range, name,
    )?;

    // Execute.
    let executor = normalize_refactor::RefactoringExecutor {
        root: root.clone(),
        dry_run,
        shadow_enabled,
        message: message.map(String::from),
    };
    let modified = executor.apply(&outcome.plan)?;

    if !dry_run {
        notify_daemon_after_edit(&root, &modified);
    }

    Ok(IntroduceVariableReport {
        file: rel_path,
        name: outcome.name,
        inserted_line: outcome.inserted_line,
        replaced_start: outcome.replaced_start,
        replaced_end: outcome.replaced_end,
        dry_run,
    })
}

// ── inline_variable ──────────────────────────────────────────────────────────

/// Report returned by `normalize edit inline-variable`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct InlineVariableReport {
    /// Relative file path that was (or would be) edited.
    pub file: String,
    /// The variable name that was inlined.
    pub variable_name: String,
    /// Number of use-sites replaced.
    pub references_replaced: usize,
    /// Whether this was a dry run (no file written).
    pub dry_run: bool,
    /// Warnings emitted during planning (e.g. side-effect risk).
    pub warnings: Vec<String>,
}

impl OutputFormatter for InlineVariableReport {
    fn format_text(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "Would inline"
        } else {
            "Inlined"
        };
        let _ = writeln!(
            out,
            "{} variable '{}' in {} ({} reference{})",
            verb,
            self.variable_name,
            self.file,
            self.references_replaced,
            if self.references_replaced == 1 {
                ""
            } else {
                "s"
            }
        );
        for w in &self.warnings {
            let _ = writeln!(out, "  warning: {}", w);
        }
        if self.dry_run {
            let _ = writeln!(out, "  (dry run — no files written)");
        }
        out
    }

    fn format_pretty(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "\x1b[1;33mWould inline\x1b[0m"
        } else {
            "\x1b[1;32mInlined\x1b[0m"
        };
        let _ = writeln!(
            out,
            "{} variable '\x1b[1m{}\x1b[0m' in \x1b[2m{}\x1b[0m ({} reference{})",
            verb,
            self.variable_name,
            self.file,
            self.references_replaced,
            if self.references_replaced == 1 {
                ""
            } else {
                "s"
            }
        );
        for w in &self.warnings {
            let _ = writeln!(out, "  \x1b[0;33mwarning\x1b[0m: {}", w);
        }
        if self.dry_run {
            let _ = writeln!(out, "  \x1b[2m(dry run — no files written)\x1b[0m");
        }
        out
    }
}

fn do_inline_variable(
    file: &str,
    position: &str,
    dry_run: bool,
    safe: bool,
    root: Option<&Path>,
    message: Option<&str>,
) -> Result<InlineVariableReport, String> {
    let root: PathBuf = root
        .map(|p| p.to_path_buf())
        // normalize-syntax-allow: rust/unwrap-in-impl - current_dir() only fails if cwd deleted
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let config = NormalizeConfig::load(&root);
    let shadow_enabled = config.shadow.enabled();

    // Resolve file path (accept relative or absolute).
    let file_path = if Path::new(file).is_absolute() {
        PathBuf::from(file)
    } else {
        root.join(file)
    };

    let rel_path = file_path
        .strip_prefix(&root)
        .unwrap_or(&file_path)
        .to_string_lossy()
        .to_string();

    let content = std::fs::read_to_string(&file_path)
        .map_err(|e| format!("Error reading {}: {}", rel_path, e))?;

    // Parse position (line:col).
    let (line, col) = parse_line_col_position(position)?;

    // Plan the refactoring.
    let outcome =
        normalize_refactor::inline_variable::plan_inline_variable(&file_path, &content, line, col)?;

    // --safe: refuse if variable has zero uses.
    if safe && outcome.references_replaced == 0 {
        return Err(format!(
            "Variable '{}' has no uses; use without --safe to remove it anyway",
            outcome.name
        ));
    }

    // Print warnings to stderr before executing.
    for w in &outcome.plan.warnings {
        eprintln!("warning: {}", w);
    }

    // Execute.
    let executor = normalize_refactor::RefactoringExecutor {
        root: root.clone(),
        dry_run,
        shadow_enabled,
        message: message.map(String::from),
    };
    let modified = executor.apply(&outcome.plan)?;

    if !dry_run {
        notify_daemon_after_edit(&root, &modified);
    }

    Ok(InlineVariableReport {
        file: rel_path,
        variable_name: outcome.name,
        references_replaced: outcome.references_replaced,
        dry_run,
        warnings: outcome.plan.warnings,
    })
}

/// Parse a `line:col` position string (1-based).
fn parse_line_col_position(s: &str) -> Result<(usize, usize), String> {
    let (line_s, col_s) = s.split_once(':').ok_or_else(|| {
        format!(
            "Invalid position '{}': expected format line:col (e.g. 5:9)",
            s
        )
    })?;
    let line: usize = line_s
        .parse()
        .map_err(|_| format!("Invalid line number '{}' in position '{}'", line_s, s))?;
    let col: usize = col_s
        .parse()
        .map_err(|_| format!("Invalid column number '{}' in position '{}'", col_s, s))?;
    if line == 0 || col == 0 {
        return Err(format!(
            "Line and column numbers are 1-based; got {} in position '{}'",
            s, s
        ));
    }
    Ok((line, col))
}

// ── add_parameter ─────────────────────────────────────────────────────────────

/// Report returned by `normalize edit add-parameter`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AddParameterReport {
    /// File containing the function definition (relative path).
    pub function_file: String,
    /// Name of the function that was modified.
    pub function_name: String,
    /// Name of the new parameter.
    pub param_name: String,
    /// Number of call sites that were updated.
    pub call_sites_updated: usize,
    /// Whether this was a dry run.
    pub dry_run: bool,
}

impl OutputFormatter for AddParameterReport {
    fn format_text(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run { "Would add" } else { "Added" };
        let _ = writeln!(
            out,
            "{} parameter '{}' to '{}' in {}",
            verb, self.param_name, self.function_name, self.function_file
        );
        if self.call_sites_updated > 0 {
            let sites = if self.call_sites_updated == 1 {
                "site"
            } else {
                "sites"
            };
            let verb2 = if self.dry_run {
                "would update"
            } else {
                "updated"
            };
            let _ = writeln!(
                out,
                "  {} {} call {}",
                verb2, self.call_sites_updated, sites
            );
        }
        if self.dry_run {
            let _ = writeln!(out, "  (dry run — no files written)");
        }
        out
    }

    fn format_pretty(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "\x1b[1;33mWould add\x1b[0m"
        } else {
            "\x1b[1;32mAdded\x1b[0m"
        };
        let _ = writeln!(
            out,
            "{} parameter '\x1b[1m{}\x1b[0m' to '\x1b[1m{}\x1b[0m' in \x1b[2m{}\x1b[0m",
            verb, self.param_name, self.function_name, self.function_file
        );
        if self.call_sites_updated > 0 {
            let sites = if self.call_sites_updated == 1 {
                "site"
            } else {
                "sites"
            };
            let verb2 = if self.dry_run {
                "would update"
            } else {
                "updated"
            };
            let _ = writeln!(
                out,
                "  {} \x1b[1m{}\x1b[0m call {}",
                verb2, self.call_sites_updated, sites
            );
        }
        if self.dry_run {
            let _ = writeln!(out, "  \x1b[2m(dry run — no files written)\x1b[0m");
        }
        out
    }
}

#[allow(clippy::too_many_arguments)]
async fn do_add_parameter(
    file: &str,
    function_name: &str,
    param_name: &str,
    default_value: &str,
    param_type: Option<&str>,
    position: Option<usize>,
    root: Option<&Path>,
    dry_run: bool,
    message: Option<&str>,
) -> Result<AddParameterReport, String> {
    let root: PathBuf = root
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let config = NormalizeConfig::load(&root);
    let shadow_enabled = config.shadow.enabled();

    let file_path = if Path::new(file).is_absolute() {
        PathBuf::from(file)
    } else {
        root.join(file)
    };

    let rel_path = file_path
        .strip_prefix(&root)
        .unwrap_or(&file_path)
        .to_string_lossy()
        .to_string();

    let index = match crate::index::ensure_ready(&root).await {
        Ok(idx) => Some(idx),
        Err(e) => {
            eprintln!(
                "warning: index not available ({}); updating definition only",
                e
            );
            None
        }
    };

    let ctx = normalize_refactor::RefactoringContext {
        root: root.clone(),
        editor: edit::Editor::new(),
        index,
        loader: normalize_languages::GrammarLoader::new(),
    };

    let outcome = normalize_refactor::add_parameter::plan_add_parameter(
        &ctx,
        &rel_path,
        function_name,
        param_name,
        param_type,
        default_value,
        position,
    )
    .await?;

    for warning in &outcome.plan.warnings {
        eprintln!("warning: {}", warning);
    }

    let executor = normalize_refactor::RefactoringExecutor {
        root: root.clone(),
        dry_run,
        shadow_enabled,
        message: message.map(String::from),
    };
    let modified = executor.apply(&outcome.plan)?;

    if !dry_run {
        notify_daemon_after_edit(&root, &modified);
    }

    Ok(AddParameterReport {
        function_file: rel_path,
        function_name: function_name.to_string(),
        param_name: param_name.to_string(),
        call_sites_updated: outcome.call_sites_updated,
        dry_run,
    })
}

// ── move ─────────────────────────────────────────────────────────────────────

/// Report returned by `normalize edit move`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MoveReport {
    /// The symbol that was (or would be) moved.
    pub symbol: String,
    /// Source file (relative path).
    pub from_file: String,
    /// Destination file (relative path).
    pub to_file: String,
    /// Whether the definition was relocated (false if dry-run aborted, etc.).
    pub definition_moved: bool,
    /// Number of import sites successfully rewritten.
    pub import_sites_updated: usize,
    /// Number of import sites that could not be rewritten confidently.
    pub import_sites_skipped: usize,
    /// Whether a re-export stub was left at the source location.
    pub reexport_added: bool,
    /// Whether this was a dry run (no files written).
    pub dry_run: bool,
    /// Files modified (or that would be modified on dry-run).
    pub files: Vec<String>,
    /// Warnings emitted during planning (e.g. unsupported re-export language,
    /// import sites skipped because the new module path could not be derived).
    pub warnings: Vec<String>,
}

impl OutputFormatter for MoveReport {
    fn format_text(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run { "Would move" } else { "Moved" };
        let _ = writeln!(
            out,
            "{} '{}' from {} to {}",
            verb, self.symbol, self.from_file, self.to_file
        );
        let _ = writeln!(
            out,
            "  imports: {} updated, {} skipped",
            self.import_sites_updated, self.import_sites_skipped
        );
        if self.reexport_added {
            let _ = writeln!(out, "  re-export left at {}", self.from_file);
        }
        if !self.files.is_empty() {
            let _ = writeln!(out, "  files: {}", self.files.join(", "));
        }
        for w in &self.warnings {
            let _ = writeln!(out, "  warning: {}", w);
        }
        out
    }

    fn format_pretty(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "\x1b[1;33mWould move\x1b[0m"
        } else {
            "\x1b[1;32mMoved\x1b[0m"
        };
        let _ = writeln!(
            out,
            "{} '\x1b[1m{}\x1b[0m' from \x1b[2m{}\x1b[0m to \x1b[2m{}\x1b[0m",
            verb, self.symbol, self.from_file, self.to_file
        );
        let _ = writeln!(
            out,
            "  imports: \x1b[1;32m{}\x1b[0m updated, \x1b[1;33m{}\x1b[0m skipped",
            self.import_sites_updated, self.import_sites_skipped
        );
        if self.reexport_added {
            let _ = writeln!(out, "  re-export left at \x1b[2m{}\x1b[0m", self.from_file);
        }
        if !self.files.is_empty() {
            let _ = writeln!(out, "  files: {}", self.files.join(", "));
        }
        for w in &self.warnings {
            let _ = writeln!(out, "  \x1b[0;33mwarning\x1b[0m: {}", w);
        }
        out
    }
}

async fn do_move(
    target: &str,
    destination: &str,
    root: Option<&Path>,
    dry_run: bool,
    reexport: bool,
    message: Option<&str>,
) -> Result<MoveReport, String> {
    let root = root
        .map(|p| p.to_path_buf())
        // normalize-syntax-allow: rust/unwrap-in-impl - current_dir() only fails if cwd was deleted
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let config = NormalizeConfig::load(&root);
    let shadow_enabled = config.shadow.enabled();

    // Resolve the target → file + symbol name.
    let unified = path_resolve::resolve_unified(target, &root)
        .ok_or_else(|| format!("No matches for: {}", target))?;
    if unified.symbol_path.is_empty() {
        return Err(format!(
            "Target must include a symbol name (e.g. path/SymbolName), got: {}",
            target
        ));
    }
    let from_rel_path = unified.file_path.clone();
    // normalize-syntax-allow: rust/unwrap-in-impl - symbol_path is non-empty (checked above)
    let symbol_name = unified.symbol_path.last().unwrap().clone();

    // Normalise destination to a path relative to root (accept absolute paths too).
    let dest_input = Path::new(destination);
    let to_rel_path = if dest_input.is_absolute() {
        dest_input
            .strip_prefix(&root)
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_else(|_| destination.to_string())
    } else {
        destination.to_string()
    };

    // Build refactoring context.
    let index = match crate::index::ensure_ready(&root).await {
        Ok(idx) => Some(idx),
        Err(e) => {
            eprintln!(
                "warning: index not available ({}); moving definition only",
                e
            );
            None
        }
    };

    let ctx = normalize_refactor::RefactoringContext {
        root: root.clone(),
        editor: edit::Editor::new(),
        index,
        loader: normalize_languages::GrammarLoader::new(),
    };

    let outcome = normalize_refactor::move_item::plan_move(
        &ctx,
        &from_rel_path,
        &to_rel_path,
        &symbol_name,
        reexport,
    )
    .await?;

    for w in &outcome.plan.warnings {
        eprintln!("warning: {}", w);
    }

    let executor = normalize_refactor::RefactoringExecutor {
        root: root.clone(),
        dry_run,
        shadow_enabled,
        message: message.map(String::from),
    };

    let modified = executor.apply(&outcome.plan)?;

    if !dry_run {
        notify_daemon_after_edit(&root, &modified);
    }

    Ok(MoveReport {
        symbol: outcome.symbol,
        from_file: outcome.from_file,
        to_file: outcome.to_file,
        definition_moved: outcome.definition_moved,
        import_sites_updated: outcome.import_sites_updated,
        import_sites_skipped: outcome.import_sites_skipped,
        reexport_added: outcome.reexport_added,
        dry_run,
        files: modified,
        warnings: outcome.plan.warnings,
    })
}

// ── inline-function ───────────────────────────────────────────────────────────

/// Report returned by `normalize edit inline-function`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct InlineFunctionReport {
    /// The function that was (or would be) inlined.
    pub function_name: String,
    /// File containing the function (relative path).
    pub file: String,
    /// Line number of the call site where the function was inlined.
    pub call_site_line: usize,
    /// Whether this was a dry run (no files written).
    pub dry_run: bool,
    /// Files modified (or that would be modified on dry-run).
    pub files: Vec<String>,
}

impl OutputFormatter for InlineFunctionReport {
    fn format_text(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "Would inline"
        } else {
            "Inlined"
        };
        let _ = writeln!(
            out,
            "{} '{}' at {}:{}",
            verb, self.function_name, self.file, self.call_site_line
        );
        if !self.files.is_empty() {
            let _ = writeln!(out, "  files: {}", self.files.join(", "));
        }
        out
    }

    fn format_pretty(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "\x1b[1;33mWould inline\x1b[0m"
        } else {
            "\x1b[1;32mInlined\x1b[0m"
        };
        let _ = writeln!(
            out,
            "{} '\x1b[1m{}\x1b[0m' at \x1b[2m{}:{}\x1b[0m",
            verb, self.function_name, self.file, self.call_site_line
        );
        if !self.files.is_empty() {
            let _ = writeln!(out, "  files: {}", self.files.join(", "));
        }
        out
    }
}

async fn do_inline_function(
    file: &str,
    position: &str,
    root: Option<&Path>,
    dry_run: bool,
    force: bool,
    message: Option<&str>,
) -> Result<InlineFunctionReport, String> {
    let root = root
        .map(|p| p.to_path_buf())
        // normalize-syntax-allow: rust/unwrap-in-impl - current_dir() only fails if cwd was deleted
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let config = NormalizeConfig::load(&root);
    let shadow_enabled = config.shadow.enabled();

    // Parse position argument "line:col" (col is optional, defaults to 1).
    let (line, col) = parse_line_col(position)?;

    // Resolve the file path.
    let file_abs = if Path::new(file).is_absolute() {
        PathBuf::from(file)
    } else {
        root.join(file)
    };
    let rel_path = file_abs
        .strip_prefix(&root)
        .unwrap_or(&file_abs)
        .to_string_lossy()
        .to_string();

    let content = std::fs::read_to_string(&file_abs)
        .map_err(|e| format!("Error reading {}: {}", rel_path, e))?;

    let ctx = normalize_refactor::RefactoringContext {
        root: root.clone(),
        editor: edit::Editor::new(),
        index: None,
        loader: normalize_languages::GrammarLoader::new(),
    };

    let outcome = normalize_refactor::inline_function::plan_inline_function(
        &ctx, &file_abs, &content, line, col, force,
    )?;

    // Print warnings
    for warning in &outcome.plan.warnings {
        eprintln!("warning: {}", warning);
    }

    let executor = normalize_refactor::RefactoringExecutor {
        root: root.clone(),
        dry_run,
        shadow_enabled,
        message: message.map(String::from),
    };

    let modified = executor.apply(&outcome.plan)?;

    if !dry_run {
        notify_daemon_after_edit(&root, &modified);
    }

    Ok(InlineFunctionReport {
        function_name: outcome.function_name,
        file: rel_path,
        call_site_line: outcome.call_site_line,
        dry_run,
        files: modified,
    })
}

/// Parse a `"line:col"` or `"line"` string into (line, col), both 1-based.
// ─── ExtractFunctionReport ────────────────────────────────────────────────────

#[derive(Debug, Serialize, JsonSchema)]
pub struct ExtractFunctionReport {
    pub function_name: String,
    pub file: String,
    pub call_site_line: u32,
    pub parameters: Vec<String>,
    pub return_type: String,
    pub is_async: bool,
    pub is_generator: bool,
    pub warnings: Vec<String>,
    pub dry_run: bool,
    pub files: Vec<String>,
}

impl OutputFormatter for ExtractFunctionReport {
    fn format_text(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "Would extract"
        } else {
            "Extracted"
        };
        let _ = writeln!(
            out,
            "{} function '{}' from {}:{}",
            verb, self.function_name, self.file, self.call_site_line
        );
        if !self.parameters.is_empty() {
            let _ = writeln!(out, "  parameters: {}", self.parameters.join(", "));
        }
        if self.return_type != "()" {
            let _ = writeln!(out, "  return: {}", self.return_type);
        }
        if self.is_async {
            let _ = writeln!(out, "  async: yes");
        }
        if self.is_generator {
            let _ = writeln!(out, "  generator: yes");
        }
        for w in &self.warnings {
            let _ = writeln!(out, "  warning: {}", w);
        }
        if !self.files.is_empty() {
            let _ = writeln!(out, "  files: {}", self.files.join(", "));
        }
        out
    }

    fn format_pretty(&self) -> String {
        use std::fmt::Write as _;
        let mut out = String::new();
        let verb = if self.dry_run {
            "\x1b[1;33mWould extract\x1b[0m"
        } else {
            "\x1b[1;32mExtracted\x1b[0m"
        };
        let _ = writeln!(
            out,
            "{} function '\x1b[1m{}\x1b[0m' from \x1b[2m{}:{}\x1b[0m",
            verb, self.function_name, self.file, self.call_site_line
        );
        if !self.parameters.is_empty() {
            let _ = writeln!(out, "  parameters: {}", self.parameters.join(", "));
        }
        if self.return_type != "()" {
            let _ = writeln!(out, "  return: {}", self.return_type);
        }
        if self.is_async {
            let _ = writeln!(out, "  async: yes");
        }
        if self.is_generator {
            let _ = writeln!(out, "  generator: yes");
        }
        for w in &self.warnings {
            let _ = writeln!(out, "  \x1b[1;33mwarning:\x1b[0m {}", w);
        }
        if !self.files.is_empty() {
            let _ = writeln!(out, "  files: {}", self.files.join(", "));
        }
        out
    }
}

async fn do_extract_function(
    file: &str,
    lines_arg: &str,
    function_name: &str,
    root: Option<&Path>,
    dry_run: bool,
    message: Option<&str>,
) -> Result<ExtractFunctionReport, String> {
    let root = root
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));

    let config = NormalizeConfig::load(&root);
    let shadow_enabled = config.shadow.enabled();

    let (start_line, end_line) = normalize_refactor::extract_function::parse_line_range(lines_arg)?;

    let file_abs = if Path::new(file).is_absolute() {
        PathBuf::from(file)
    } else {
        root.join(file)
    };
    let rel_path = file_abs
        .strip_prefix(&root)
        .unwrap_or(&file_abs)
        .to_string_lossy()
        .to_string();

    let content = std::fs::read_to_string(&file_abs)
        .map_err(|e| format!("Error reading {}: {}", rel_path, e))?;

    let index = match crate::index::ensure_ready(&root).await {
        Ok(idx) => idx,
        Err(e) => {
            return Err(format!(
                "extract-function requires the facts index: {}; run `normalize structure rebuild` first",
                e
            ));
        }
    };

    let ctx = normalize_refactor::RefactoringContext {
        root: root.clone(),
        editor: edit::Editor::new(),
        index: Some(index),
        loader: normalize_languages::GrammarLoader::new(),
    };

    let outcome = normalize_refactor::extract_function::plan_extract_function(
        &ctx,
        &file_abs,
        &content,
        start_line,
        end_line,
        function_name,
    )
    .await?;

    for warning in &outcome.plan.warnings {
        eprintln!("warning: {}", warning);
    }

    let executor = normalize_refactor::RefactoringExecutor {
        root: root.clone(),
        dry_run,
        shadow_enabled,
        message: message.map(String::from),
    };
    let modified = executor.apply(&outcome.plan)?;

    if !dry_run {
        notify_daemon_after_edit(&root, &modified);
    }

    let return_type_str = match &outcome.return_type {
        normalize_refactor::extract_function::ReturnType::Unit => "()".to_string(),
        normalize_refactor::extract_function::ReturnType::Single(v) => v.clone(),
        normalize_refactor::extract_function::ReturnType::Tuple(vs) => {
            format!("({})", vs.join(", "))
        }
        normalize_refactor::extract_function::ReturnType::Result(ok, err) => {
            format!("Result<{}, {}>", ok, err)
        }
    };

    Ok(ExtractFunctionReport {
        function_name: outcome.function_name,
        file: rel_path,
        call_site_line: outcome.call_site_line,
        parameters: outcome.parameters.iter().map(|p| p.name.clone()).collect(),
        return_type: return_type_str,
        is_async: outcome.is_async,
        is_generator: outcome.is_generator,
        warnings: outcome.plan.warnings,
        dry_run,
        files: modified,
    })
}

fn parse_line_col(s: &str) -> Result<(usize, usize), String> {
    match s.split_once(':') {
        Some((l, c)) => {
            let line = l
                .trim()
                .parse::<usize>()
                .map_err(|_| format!("invalid line in position '{}': expected integer", s))?;
            let col = c
                .trim()
                .parse::<usize>()
                .map_err(|_| format!("invalid col in position '{}': expected integer", s))?;
            if line == 0 || col == 0 {
                return Err(format!(
                    "position '{}': line and col are 1-based (must be ≥ 1)",
                    s
                ));
            }
            Ok((line, col))
        }
        None => {
            let line = s
                .trim()
                .parse::<usize>()
                .map_err(|_| format!("invalid position '{}': expected 'line:col' or 'line'", s))?;
            if line == 0 {
                return Err(format!("position '{}': line is 1-based (must be ≥ 1)", s));
            }
            Ok((line, 1))
        }
    }
}
