//! On-save action execution.
//!
//! This module handles running configured actions when files are saved,
//! such as formatters, linters, and other tools.

use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;

use super::Editor;
use crate::config::{FormatterConfig, OnSaveAction};
use crate::model::event::Event;
use crate::services::process_hidden::HideWindow;
use rust_i18n::t;

/// Result of running a formatter or on-save action
enum ActionResult {
    /// Action ran successfully, contains output
    Success(String),
    /// Command not found
    CommandNotFound(String),
    /// Action failed with error
    Error(String),
}

impl Editor {
    /// Run on-save actions for the active buffer after a successful save.
    /// This includes format-on-save (if enabled) and any on_save actions.
    /// Returns Ok(true) if actions ran successfully, Ok(false) if no actions,
    /// or Err with an error message.
    pub fn run_on_save_actions(&mut self) -> Result<bool, String> {
        let path = match self.active_state().buffer.file_path() {
            Some(p) => p.to_path_buf(),
            None => return Ok(false),
        };

        let mut ran_any_action = false;

        // Run whitespace cleanup actions first (before formatter)
        if self.config.editor.trim_trailing_whitespace_on_save && self.trim_trailing_whitespace()? {
            ran_any_action = true;
        }

        if self.config.editor.ensure_final_newline_on_save && self.ensure_final_newline()? {
            ran_any_action = true;
        }

        // If whitespace cleanup made changes, re-save
        if ran_any_action {
            if let Err(e) = self.active_state_mut().buffer.save() {
                return Err(format!("Failed to re-save after whitespace cleanup: {}", e));
            }
            self.active_event_log_mut().mark_saved();
            // Refresh the recorded on-disk mtime after our own re-write (see the
            // note on the trailing `watch_file` below). This must happen here —
            // not only at the end of the function — because the language lookup
            // right below can `return` early for languages with no config
            // (e.g. plain-text files), which would otherwise skip the refresh.
            self.watch_file(&path);
        }

        // Get language from buffer's stored state
        let language = self.active_state().language.clone();

        let lang_config = match self.config.languages.get(&language) {
            Some(lc) => lc.clone(),
            None => return Ok(ran_any_action),
        };

        // Run formatter if format_on_save is enabled
        if lang_config.format_on_save {
            if let Some(ref formatter) = lang_config.formatter {
                match self.run_formatter(formatter, &path) {
                    ActionResult::Success(output) => {
                        self.replace_buffer_with_output(&output)?;
                        // Re-save after formatting
                        if let Err(e) = self.active_state_mut().buffer.save() {
                            return Err(format!("Failed to re-save after format: {}", e));
                        }
                        self.active_event_log_mut().mark_saved();
                        // Refresh the recorded on-disk mtime after our own write.
                        self.watch_file(&path);
                        ran_any_action = true;
                    }
                    ActionResult::CommandNotFound(cmd) => {
                        self.active_window_mut().status_message = Some(format!(
                            "Formatter '{}' not found (install it for auto-formatting)",
                            cmd
                        ));
                    }
                    ActionResult::Error(e) => {
                        return Err(e);
                    }
                }
            }
        }

        // Run on_save actions (linters, etc.)
        let project_root = std::env::current_dir()
            .unwrap_or_else(|_| path.parent().unwrap_or(Path::new(".")).to_path_buf());

        for action in &lang_config.on_save {
            if !action.enabled {
                continue;
            }

            match self.run_on_save_action(action, &path, &project_root) {
                ActionResult::Success(_) => {
                    ran_any_action = true;
                }
                ActionResult::CommandNotFound(_) => {
                    // Skip missing optional commands silently
                }
                ActionResult::Error(e) => {
                    return Err(e);
                }
            }
        }

        // A file-modifying on-save linter (e.g. one that rewrites `$FILE` in
        // place) also advances the on-disk mtime past the value recorded by
        // `finalize_save_buffer`, which ran *before* these actions. Refresh the
        // recorded on-disk state one final time so a subsequent save's conflict
        // check and the auto-revert poll don't mistake the editor's own on-save
        // write for an external modification. Without keeping this state in sync
        // the next Ctrl+S false-positives a "File changed on disk" prompt
        // (#2706) and the poll fires a spurious "Reverted to saved file" reload
        // (#2711).
        if ran_any_action {
            self.watch_file(&path);
        }

        Ok(ran_any_action)
    }

    /// Format the current buffer using the configured formatter.
    /// Returns Ok(()) if formatting succeeded, or Err with an error message.
    pub fn format_buffer(&mut self) -> Result<(), String> {
        let path = match self.active_state().buffer.file_path() {
            Some(p) => p.to_path_buf(),
            None => {
                return Err(
                    "Cannot format unsaved buffer (save first to detect language)".to_string(),
                )
            }
        };

        // Get language from buffer's stored state
        let language = self.active_state().language.clone();

        // An active selection means the user wants to reformat only that
        // region (as in VS Code's "Format Selection"). External formatters can
        // only rewrite the whole file, so when a selection is active and the
        // language server supports range formatting, route through LSP range
        // formatting instead of silently reformatting the entire buffer.
        if self.active_cursors().primary().selection_range().is_some()
            && self.active_lsp_supports_range_formatting()
        {
            self.request_formatting();
            return Ok(());
        }

        // Get formatter for this language
        let formatter = self
            .config
            .languages
            .get(&language)
            .and_then(|lc| lc.formatter.clone());

        let formatter = match formatter {
            Some(f) => f,
            None => {
                // No external formatter — try LSP formatting
                self.request_formatting();
                return Ok(());
            }
        };

        match self.run_formatter(&formatter, &path) {
            ActionResult::Success(output) => {
                self.replace_buffer_with_output(&output)?;
                self.set_status_message(
                    t!(
                        "format.formatted_with",
                        formatter = formatter.command.clone()
                    )
                    .to_string(),
                );
                Ok(())
            }
            ActionResult::CommandNotFound(cmd) => Err(format!("Formatter '{}' not found", cmd)),
            ActionResult::Error(e) => Err(e),
        }
    }

    /// Run a formatter on the current buffer content.
    fn run_formatter(&mut self, formatter: &FormatterConfig, file_path: &Path) -> ActionResult {
        let file_path_str = file_path.display().to_string();

        // Check if command exists
        if !command_exists(&formatter.command) {
            return ActionResult::CommandNotFound(formatter.command.clone());
        }

        // Build the command
        let shell = detect_shell();

        // Build the full command string with arguments
        let mut cmd_parts = vec![formatter.command.clone()];
        for arg in &formatter.args {
            cmd_parts.push(arg.replace("$FILE", &file_path_str));
        }

        let full_command = cmd_parts.join(" ");

        // Get project root for working directory
        let project_root = std::env::current_dir()
            .unwrap_or_else(|_| file_path.parent().unwrap_or(Path::new(".")).to_path_buf());

        // Set up the command
        let mut cmd = Command::new(&shell);
        cmd.args(["-c", &full_command])
            .current_dir(&project_root)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .hide_window();

        if formatter.stdin {
            cmd.stdin(Stdio::piped());
        } else {
            cmd.stdin(Stdio::null());
        }

        // Spawn the process
        let mut child = match cmd.spawn() {
            Ok(c) => c,
            Err(e) => {
                return ActionResult::Error(format!(
                    "Failed to run '{}': {}",
                    formatter.command, e
                ));
            }
        };

        // Stream buffer content to stdin on a background thread so that
        // we can drain stdout/stderr concurrently. Without this, any
        // formatter whose output exceeds the kernel's pipe buffer
        // (~64KB on Linux) blocks writing stdout, which in turn blocks
        // waiting for `wait_with_output` and produces the "Format Buffer
        // hangs" symptom (issue #1573).
        let stdin_writer = if formatter.stdin {
            let content = self.active_state().buffer.to_string().unwrap_or_default();
            child.stdin.take().map(|mut stdin| {
                std::thread::spawn(move || -> std::io::Result<()> {
                    stdin.write_all(content.as_bytes())?;
                    stdin.flush()?;
                    // Dropping `stdin` here closes the pipe so the child
                    // sees EOF and exits once it has consumed the input.
                    Ok(())
                })
            })
        } else {
            None
        };

        // Enforce the timeout on an auxiliary thread that kills the child
        // by PID if it has not finished within `timeout_ms`.
        // `wait_with_output` blocks until the child closes its
        // stdout/stderr, so without a watchdog a truly stuck process
        // would never time out.
        let timeout = Duration::from_millis(formatter.timeout_ms);
        let timed_out = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
        let child_finished = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
        let child_pid = child.id();
        let watchdog = {
            let timed_out = std::sync::Arc::clone(&timed_out);
            let child_finished = std::sync::Arc::clone(&child_finished);
            std::thread::spawn(move || {
                let start = std::time::Instant::now();
                while start.elapsed() < timeout {
                    if child_finished.load(std::sync::atomic::Ordering::SeqCst) {
                        return;
                    }
                    std::thread::sleep(Duration::from_millis(50));
                }
                timed_out.store(true, std::sync::atomic::Ordering::SeqCst);
                #[cfg(unix)]
                {
                    // SAFETY: libc::kill is async-signal-safe and we only
                    // signal a child we spawned.
                    unsafe {
                        libc::kill(child_pid as i32, libc::SIGKILL);
                    }
                }
                #[cfg(not(unix))]
                {
                    let _ = child_pid;
                }
            })
        };

        // Wait for the child and collect output. `wait_with_output`
        // internally reads stdout and stderr on separate threads, so
        // large outputs cannot deadlock the way they did with the old
        // `try_wait`-polling implementation.
        let output_result = child.wait_with_output();
        child_finished.store(true, std::sync::atomic::Ordering::SeqCst);

        // Join the stdin writer (it has either finished or will finish
        // once the child closes its end of the pipe, which happens on
        // exit or SIGKILL).
        if let Some(handle) = stdin_writer {
            match handle.join() {
                Ok(Ok(())) => {}
                // If the child exited before we finished writing, a
                // `BrokenPipe` is expected and not a hard error; we
                // still want the output the formatter produced.
                Ok(Err(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
                Ok(Err(e)) => {
                    return ActionResult::Error(format!("Failed to write to stdin: {}", e));
                }
                Err(_) => {
                    return ActionResult::Error("stdin writer thread panicked".to_string());
                }
            }
        }
        if let Err(e) = watchdog.join() {
            tracing::warn!("formatter watchdog thread panicked: {e:?}");
        }

        if timed_out.load(std::sync::atomic::Ordering::SeqCst) {
            return ActionResult::Error(format!(
                "Formatter '{}' timed out after {}ms",
                formatter.command, formatter.timeout_ms
            ));
        }

        let output = match output_result {
            Ok(o) => o,
            Err(e) => return ActionResult::Error(format!("Failed to get output: {}", e)),
        };

        if output.status.success() {
            match String::from_utf8(output.stdout) {
                Ok(s) => ActionResult::Success(s),
                Err(e) => ActionResult::Error(format!("Invalid UTF-8 in output: {}", e)),
            }
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr);
            let stdout = String::from_utf8_lossy(&output.stdout);
            let error_output = if !stderr.is_empty() {
                stderr.trim().to_string()
            } else if !stdout.is_empty() {
                stdout.trim().to_string()
            } else {
                format!("exit code {:?}", output.status.code())
            };
            ActionResult::Error(format!(
                "Formatter '{}' failed: {}",
                formatter.command, error_output
            ))
        }
    }

    /// Run a single on-save action (linter, etc.).
    fn run_on_save_action(
        &mut self,
        action: &OnSaveAction,
        file_path: &Path,
        project_root: &Path,
    ) -> ActionResult {
        let file_path_str = file_path.display().to_string();

        // Check if command exists
        if !command_exists(&action.command) {
            return ActionResult::CommandNotFound(action.command.clone());
        }

        // Build the command
        let shell = detect_shell();

        let mut cmd_parts = vec![action.command.clone()];
        for arg in &action.args {
            cmd_parts.push(arg.replace("$FILE", &file_path_str));
        }

        // If no arguments contain $FILE, append the file path
        let has_file_arg = action.args.iter().any(|a| a.contains("$FILE"));
        if !has_file_arg && !action.stdin {
            cmd_parts.push(file_path_str.clone());
        }

        let full_command = cmd_parts.join(" ");

        // Determine working directory
        let working_dir = action
            .working_dir
            .as_ref()
            .map(|wd| {
                let expanded = wd.replace("$FILE", &file_path_str);
                Path::new(&expanded).to_path_buf()
            })
            .unwrap_or_else(|| project_root.to_path_buf());

        // Set up the command
        let mut cmd = Command::new(&shell);
        cmd.args(["-c", &full_command])
            .current_dir(&working_dir)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .hide_window();

        if action.stdin {
            cmd.stdin(Stdio::piped());
        } else {
            cmd.stdin(Stdio::null());
        }

        // Spawn the process
        let mut child = match cmd.spawn() {
            Ok(c) => c,
            Err(e) => {
                return ActionResult::Error(format!("Failed to run '{}': {}", action.command, e));
            }
        };

        // Write buffer content to stdin if configured
        if action.stdin {
            let content = self.active_state().buffer.to_string().unwrap_or_default();
            if let Some(mut stdin) = child.stdin.take() {
                if let Err(e) = stdin.write_all(content.as_bytes()) {
                    return ActionResult::Error(format!("Failed to write to stdin: {}", e));
                }
            }
        }

        // Wait for the process with timeout
        let timeout = Duration::from_millis(action.timeout_ms);
        let start = std::time::Instant::now();

        loop {
            match child.try_wait() {
                Ok(Some(status)) => {
                    let output = match child.wait_with_output() {
                        Ok(o) => o,
                        Err(e) => {
                            return ActionResult::Error(format!("Failed to get output: {}", e))
                        }
                    };

                    if status.success() {
                        return match String::from_utf8(output.stdout) {
                            Ok(s) => ActionResult::Success(s),
                            Err(e) => {
                                ActionResult::Error(format!("Invalid UTF-8 in output: {}", e))
                            }
                        };
                    } else {
                        let stderr = String::from_utf8_lossy(&output.stderr);
                        let stdout = String::from_utf8_lossy(&output.stdout);
                        let error_output = if !stderr.is_empty() {
                            stderr.trim().to_string()
                        } else if !stdout.is_empty() {
                            stdout.trim().to_string()
                        } else {
                            format!("exit code {:?}", status.code())
                        };
                        return ActionResult::Error(format!(
                            "On-save action '{}' failed: {}",
                            action.command, error_output
                        ));
                    }
                }
                Ok(None) => {
                    if start.elapsed() > timeout {
                        // Best-effort kill of timed-out process.
                        #[allow(clippy::let_underscore_must_use)]
                        let _ = child.kill();
                        return ActionResult::Error(format!(
                            "On-save action '{}' timed out after {}ms",
                            action.command, action.timeout_ms
                        ));
                    }
                    std::thread::sleep(Duration::from_millis(10));
                }
                Err(e) => {
                    return ActionResult::Error(format!(
                        "Failed to wait for '{}': {}",
                        action.command, e
                    ));
                }
            }
        }
    }

    /// Replace the active buffer's content with new output.
    fn replace_buffer_with_output(&mut self, output: &str) -> Result<(), String> {
        let cursor_id = self.active_cursors().primary_id();

        // Get current buffer content
        let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();

        // Only replace if content is different
        if buffer_content == output {
            return Ok(());
        }

        let buffer_len = buffer_content.len();

        // Capture cursor position and selection state before replacement
        let old_cursor_pos = self.active_cursors().primary().position;
        let old_anchor = self.active_cursors().primary().anchor;
        let old_sticky_column = self.active_cursors().primary().sticky_column;

        // Delete all content and insert new
        let delete_event = Event::Delete {
            range: 0..buffer_len,
            deleted_text: buffer_content,
            cursor_id,
        };
        let insert_event = Event::Insert {
            position: 0,
            text: output.to_string(),
            cursor_id,
        };

        // After delete+insert, cursor will be at output.len()
        // Restore cursor to original position (or clamp to new buffer length)
        let new_buffer_len = output.len();
        let new_cursor_pos = old_cursor_pos.min(new_buffer_len);

        // Leading cursor-restore event. Applied forward this is a no-op (the
        // cursor is already at `old_cursor_pos`), but a `Batch` is undone by
        // applying the *inverse* of each event in reverse order, so this
        // event's inverse runs last on undo and pins the cursor/selection back
        // to exactly where it was before the rewrite. Without it, the last
        // inverse applied on undo is the re-`Insert` of the original text,
        // which leaves the cursor at the end of the buffer and scrolls the
        // view to the bottom of the file (issue #2027).
        let restore_cursor_event = Event::MoveCursor {
            cursor_id,
            old_position: old_cursor_pos,
            new_position: old_cursor_pos,
            old_anchor,
            new_anchor: old_anchor,
            old_sticky_column,
            new_sticky_column: old_sticky_column,
        };

        // Only add MoveCursor event if position actually changes
        let mut events = vec![restore_cursor_event, delete_event, insert_event];
        if new_cursor_pos != new_buffer_len {
            let move_cursor_event = Event::MoveCursor {
                cursor_id,
                old_position: new_buffer_len, // Where cursor is after insert
                new_position: new_cursor_pos,
                old_anchor: None,
                new_anchor: old_anchor.map(|a| a.min(new_buffer_len)),
                old_sticky_column: None,
                new_sticky_column: old_sticky_column,
            };
            events.push(move_cursor_event);
        }

        // Apply as a batch for atomic undo
        let batch = Event::Batch {
            events,
            description: "On-save format".to_string(),
        };
        self.active_event_log_mut().append(batch.clone());
        self.apply_event_to_active_buffer(&batch);

        Ok(())
    }

    /// Trim trailing whitespace from all lines in the active buffer.
    /// Returns Ok(true) if any changes were made, Ok(false) if buffer unchanged.
    pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
        let content = self.active_state().buffer.to_string().unwrap_or_default();
        let line_ending = self.active_state().buffer.line_ending();
        let sep = line_ending.as_str();

        // Split on the buffer's own line terminator and trim only trailing
        // *horizontal* whitespace (never the CR/LF that form the ending) from
        // each segment, then rejoin with the same terminator. Splitting on the
        // real ending (and preserving it) keeps the line-ending mode intact —
        // a CRLF buffer stays CRLF instead of collapsing to LF — and preserves
        // the exact line count, whereas the old `str::lines()` + `join("\n")`
        // approach both normalized CRLF to LF and dropped a trailing empty line
        // (#2711).
        let trimmed: String = content
            .split(sep)
            .map(|line| {
                line.trim_end_matches(|c: char| c.is_whitespace() && c != '\r' && c != '\n')
            })
            .collect::<Vec<_>>()
            .join(sep);

        if trimmed == content {
            return Ok(false);
        }

        self.replace_buffer_with_output(&trimmed)?;
        Ok(true)
    }

    /// Ensure the buffer ends with a newline.
    /// Returns Ok(true) if a newline was added, Ok(false) if already ends with newline.
    pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
        let content = self.active_state().buffer.to_string().unwrap_or_default();

        // Empty buffers don't need a newline
        if content.is_empty() {
            return Ok(false);
        }

        // Already terminated (LF, CRLF, or CR all end in one of these bytes).
        if content.ends_with('\n') || content.ends_with('\r') {
            return Ok(false);
        }

        // Append the buffer's own line ending so a CRLF buffer's final newline
        // is CRLF, not a hardcoded LF that would corrupt the line-ending mode.
        let sep = self.active_state().buffer.line_ending().as_str();
        let with_newline = format!("{}{}", content, sep);
        self.replace_buffer_with_output(&with_newline)?;
        Ok(true)
    }
}

/// Check if a command exists in the system PATH.
fn command_exists(command: &str) -> bool {
    // Use 'which' on Unix or 'where' on Windows to check if command exists
    #[cfg(unix)]
    {
        Command::new("which")
            .arg(command)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    }

    #[cfg(windows)]
    {
        Command::new("where")
            .arg(command)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .hide_window()
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    }

    #[cfg(not(any(unix, windows)))]
    {
        // On other platforms, assume command exists and let it fail at runtime
        true
    }
}

/// Detect the shell to use for executing commands.
fn detect_shell() -> String {
    // Try SHELL environment variable first
    if let Ok(shell) = std::env::var("SHELL") {
        if !shell.is_empty() {
            return shell;
        }
    }

    // Fall back to common shells
    #[cfg(unix)]
    {
        if std::path::Path::new("/bin/bash").exists() {
            return "/bin/bash".to_string();
        }
        if std::path::Path::new("/bin/sh").exists() {
            return "/bin/sh".to_string();
        }
    }

    #[cfg(windows)]
    {
        if let Ok(comspec) = std::env::var("COMSPEC") {
            return comspec;
        }
        return "cmd.exe".to_string();
    }

    // Last resort
    "sh".to_string()
}
