//! `ExpandSnippet` command handler (#136).
//!
//! Expands the snippet whose prefix matches the word before the cursor.

use std::path::Path;

use {
    reovim_driver_command::{Command, CommandContext, CommandHandler, CommandResult},
    reovim_driver_session::{
        BufferApi, ChangeTracker, ExtensionApi, ModeApi, Selection, SessionRuntime,
        TransitionContext,
    },
    reovim_kernel::api::v1::{CommandId, Position},
};

use crate::{
    engine::ActiveSnippet,
    ids,
    loader::JsonSnippetProvider,
    parser,
    provider::{SnippetProvider, SnippetRegistryHandle},
    state::SnippetSessionState,
    variables::VariableContext,
};

/// Expand snippet at cursor.
///
/// Reads the word before the cursor, looks up a snippet with that prefix,
/// parses and expands the snippet body, replaces the trigger word, and
/// enters snippet navigation mode.
pub struct ExpandSnippet {
    handle: SnippetRegistryHandle,
}

impl ExpandSnippet {
    /// Create a new expand command with access to the snippet registry.
    #[must_use]
    pub const fn new(handle: SnippetRegistryHandle) -> Self {
        Self { handle }
    }
}

impl Command for ExpandSnippet {
    fn id(&self) -> CommandId {
        ids::EXPAND
    }

    fn description(&self) -> &'static str {
        "Expand snippet at cursor"
    }
}

#[cfg_attr(coverage_nightly, coverage(off))]
impl CommandHandler for ExpandSnippet {
    fn execute(&self, runtime: &mut SessionRuntime<'_>, args: &CommandContext) -> CommandResult {
        let Some(buffer_id) = args.buffer_id() else {
            return CommandResult::error("no active buffer");
        };

        let Some(window) = runtime.windows().active() else {
            return CommandResult::error("no active window");
        };
        let cursor = Position::new(window.cursor.line, window.cursor.column);

        // Extract word before cursor as the trigger prefix
        let prefix = runtime
            .buffer_line(buffer_id, cursor.line)
            .map(|line| extract_word_before(&line, cursor.column))
            .unwrap_or_default();

        if prefix.is_empty() {
            return CommandResult::Success; // nothing to expand
        }

        // Determine filetype from buffer file path
        let filetype = runtime
            .buffer_file_path(buffer_id)
            .as_deref()
            .map(reovim_driver_vfs::filetype_id)
            .filter(|ft| !ft.is_empty())
            .unwrap_or("global");

        // Look up snippet: project-local first, then registry handle
        let definition = runtime
            .buffer_file_path(buffer_id)
            .as_deref()
            .and_then(|p| crate::project::project_snippet_dir(Path::new(p)))
            .filter(|dir| dir.exists())
            .and_then(|dir| JsonSnippetProvider::load_directory(&dir).ok())
            .and_then(|p| {
                p.snippet_by_prefix(filetype, &prefix)
                    .or_else(|| p.snippet_by_prefix("global", &prefix))
                    .cloned()
            })
            .or_else(|| self.handle.find_by_prefix(filetype, &prefix));

        let Some(definition) = definition else {
            return CommandResult::Success; // no matching snippet
        };

        // Parse the snippet body (lazy: raw string → AST)
        let body = match parser::parse(&definition.body_raw) {
            Ok(body) => body,
            Err(e) => return CommandResult::error(&format!("snippet parse error: {e}")),
        };

        // Delete the trigger word from the buffer
        let trigger_start = Position::new(cursor.line, cursor.column - prefix.len());
        runtime.delete_range(buffer_id, trigger_start, cursor);

        // Build variable context from runtime
        let var_ctx = VariableContext {
            file_path: runtime.buffer_file_path(buffer_id),
            line_number: cursor.line,
            ..VariableContext::empty()
        };

        // Expand the snippet body
        let (expanded_text, active_snippet) = ActiveSnippet::expand(&body, trigger_start, &var_ctx);

        // Insert expanded text at trigger position
        runtime.insert_text(buffer_id, trigger_start, &expanded_text);

        // Extract first tab stop info for cursor positioning / selection.
        let has_tab_stops = !active_snippet.is_done();
        let first_stop_range = active_snippet.current().map(|ts| {
            let start = ts.start;
            let end = ts.end;
            (start, end)
        });

        // Store active snippet state
        let state = runtime.ext_mut::<SnippetSessionState>();
        state.active = Some(active_snippet);

        // If there are tab stops, enter snippet navigation mode
        if has_tab_stops {
            runtime.push_mode(ids::NAVIGATING_MODE, TransitionContext::new());
            if let Some((start, end)) = first_stop_range {
                // Position cursor at tab stop start.
                // record_cursor_move MUST be called before setting selection,
                // because it auto-extends sel.end to cursor+1 (#474 visual mode).
                if let Some(w) = runtime.windows_mut().active_mut() {
                    w.cursor.line = start.line;
                    w.cursor.column = start.column;
                }
                runtime.record_cursor_move(buffer_id);

                // Now set the selection (after record_cursor_move to avoid clobbering).
                if let Some(w) = runtime.windows_mut().active_mut() {
                    if start == end {
                        w.selection = None;
                    } else {
                        w.selection = Some(Selection::character(start, end));
                    }
                }
                if start != end {
                    runtime.record_selection_change(buffer_id);
                }
            }
        }

        CommandResult::Success
    }
}

/// Extract the word immediately before the cursor column.
///
/// A "word" consists of consecutive `[a-zA-Z0-9_-]` characters.
fn extract_word_before(line: &str, col: usize) -> String {
    let bytes = line.as_bytes();
    let end = col.min(bytes.len());
    let mut start = end;

    while start > 0 && is_word_char(bytes[start - 1]) {
        start -= 1;
    }

    line[start..end].to_string()
}

/// Check if a byte is a word character for snippet prefix matching.
const fn is_word_char(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
}

#[cfg(test)]
#[path = "expand_tests.rs"]
mod tests;
