//! High-level editor navigation logic (motions).
//!
//! Motions are deterministic, side-effect-free cursor transformations over a
//! [`TextBuffer`]. They use logical [`Pos`] values: line and column are both
//! zero-based, and columns are character offsets, not visual cells.
//!
//! Frontends should apply these motions first, then project the result into
//! viewport, scrolling, and terminal-cell coordinates.

use crate::{Pos, TextBuffer};

/// A navigation intent (motion) that transforms a document cursor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Motion {
    /// Move left by one char.
    Left,
    /// Move right by one char.
    Right,
    /// Move up by one line.
    Up,
    /// Move down by one line.
    Down,

    /// Go to first line of file (`gg`).
    FileStart,

    /// Go to last line of file (`G`). Column is clamped to that line.
    FileEnd,

    /// Go to start of line (`0`-ish).
    LineStart,

    /// Go to the first non-whitespace character on the line (`_`-ish).
    LineFirstNonWhitespace,

    /// Go to end of line (`$`-ish), i.e. `line_len_chars(line)`.
    LineEnd,

    /// Move to start of previous word (`b`-ish).
    WordStartBefore,

    /// Move to start of next word (`w`-ish).
    WordStartAfter,

    /// Move to end of next word (`e`-ish).
    WordEndAfter,

    /// Move onto the next matching character on the current line (`f`-ish).
    FindChar(char),

    /// Move just before the next matching character on the current line (`t`-ish).
    TillChar(char),

    /// Move onto the previous matching character on the current line (`F`-ish).
    FindCharBefore(char),

    /// Move just after the previous matching character on the current line (`T`-ish).
    TillCharBefore(char),

    /// Jump to the delimiter paired with the delimiter under the cursor (`%`-ish).
    MatchDelimiter,
}

/// Apply a single `Motion` to a given cursor position.
///
/// This function is pure: it never mutates the buffer, and always returns a
/// position clamped to valid buffer bounds.
#[inline]
pub fn apply_motion(buffer: &TextBuffer, cursor: Pos, motion: Motion) -> Pos {
    let cursor = buffer.clamp_pos(cursor);

    match motion {
        Motion::Left => buffer.move_left(cursor),
        Motion::Right => buffer.move_right(cursor),
        Motion::Up => buffer.move_up(cursor),
        Motion::Down => buffer.move_down(cursor),

        Motion::FileStart => {
            let target_col = if cursor.line == 0 { 0 } else { cursor.col };
            buffer.clamp_pos(Pos::new(0, target_col))
        }

        Motion::FileEnd => {
            let last = buffer.len_lines().saturating_sub(1);
            buffer.clamp_pos(Pos::new(last, cursor.col))
        }

        Motion::LineStart => Pos::new(cursor.line, 0),

        Motion::LineFirstNonWhitespace => {
            let line = buffer.clamp_line(cursor.line);
            Pos::new(line, buffer.line_first_non_whitespace_col(line))
        }

        Motion::LineEnd => {
            let line = buffer.clamp_line(cursor.line);
            let end_col = buffer.line_len_chars(line);
            Pos::new(line, end_col)
        }

        Motion::WordStartBefore => buffer.word_start_before(cursor),

        Motion::WordStartAfter => buffer.word_start_after(cursor),

        Motion::WordEndAfter => buffer.word_end_after(cursor),

        Motion::FindChar(needle) => buffer
            .find_char_after_on_line(cursor, needle)
            .unwrap_or(cursor),

        Motion::TillChar(needle) => buffer
            .find_char_after_on_line(cursor, needle)
            .map(|target| {
                if target.col > 0 {
                    Pos::new(target.line, target.col - 1)
                } else {
                    target
                }
            })
            .unwrap_or(cursor),

        Motion::FindCharBefore(needle) => buffer
            .find_char_before_on_line(cursor, needle)
            .unwrap_or(cursor),

        Motion::TillCharBefore(needle) => buffer
            .find_char_before_on_line(cursor, needle)
            .map(|target| Pos::new(target.line, target.col.saturating_add(1)))
            .unwrap_or(cursor),

        Motion::MatchDelimiter => buffer.matching_delimiter(cursor).unwrap_or(cursor),
    }
}

/// Apply a motion using operator semantics for the resulting half-open range end.
pub fn apply_motion_for_operator(
    buffer: &TextBuffer,
    cursor: Pos,
    motion: Motion,
    count: usize,
) -> Pos {
    match motion {
        Motion::FindChar(needle) => {
            let mut current = buffer.clamp_pos(cursor);
            let mut target = None;
            for _ in 0..count.max(1) {
                let Some(found) = buffer.find_char_after_on_line(current, needle) else {
                    return cursor;
                };
                target = Some(found);
                current = found;
            }
            target
                .map(|found| buffer.move_right(found))
                .unwrap_or(cursor)
        }
        Motion::TillChar(needle) => {
            let mut current = buffer.clamp_pos(cursor);
            let mut target = None;
            for _ in 0..count.max(1) {
                let Some(found) = buffer.find_char_after_on_line(current, needle) else {
                    return cursor;
                };
                target = Some(found);
                current = found;
            }
            target.unwrap_or(cursor)
        }
        Motion::FindCharBefore(needle) => {
            let mut current = buffer.clamp_pos(cursor);
            let mut target = None;
            for _ in 0..count.max(1) {
                let Some(found) = buffer.find_char_before_on_line(current, needle) else {
                    return cursor;
                };
                target = Some(found);
                current = found;
            }
            target.unwrap_or(cursor)
        }
        Motion::TillCharBefore(needle) => {
            let mut current = buffer.clamp_pos(cursor);
            let mut target = None;
            for _ in 0..count.max(1) {
                let Some(found) = buffer.find_char_before_on_line(current, needle) else {
                    return cursor;
                };
                let after_found = Pos::new(found.line, found.col.saturating_add(1));
                target = Some(after_found);
                current = found;
            }
            target.unwrap_or(cursor)
        }
        Motion::MatchDelimiter => {
            let target = apply_motion(buffer, cursor, motion);
            if target > cursor {
                buffer.move_right(target)
            } else {
                target
            }
        }
        _ => apply_motion_n(buffer, cursor, motion, count),
    }
}

/// Apply a motion with a Vim-style numeric count.
///
/// - If `count == 0`, this returns `cursor` unchanged.
/// - Left/right motions use direct char-index arithmetic.
/// - Other repeated motions are applied step by step so they can stop naturally
///   at document or line boundaries.
pub fn apply_motion_n(buffer: &TextBuffer, cursor: Pos, motion: Motion, count: usize) -> Pos {
    if count == 0 {
        return buffer.clamp_pos(cursor);
    }

    match motion {
        Motion::Left => {
            let at = buffer.pos_to_char(cursor);
            return buffer.char_to_pos(at.saturating_sub(count));
        }
        Motion::Right => {
            let at = buffer.pos_to_char(cursor);
            return buffer.char_to_pos(at.saturating_add(count).min(buffer.len_chars()));
        }
        _ => {}
    }

    if motion == Motion::MatchDelimiter {
        return apply_motion(buffer, cursor, motion);
    }

    let mut cur = buffer.clamp_pos(cursor);
    for _ in 0..count {
        let next = apply_motion(buffer, cur, motion);
        // If the motion stops making progress (EOF/top/etc.), stop early.
        if next == cur {
            break;
        }
        cur = next;
    }
    cur
}

/// Convenience helpers for motions that take a count.
pub mod helpers {
    use super::{Motion, apply_motion_n};
    use crate::{Pos, TextBuffer};

    /// Move forward by words (`w`-ish) by applying `WordStartAfter` repeatedly.
    #[inline]
    pub fn word_forward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
        apply_motion_n(buffer, cursor, Motion::WordStartAfter, count)
    }

    /// Move backward by words (`b`-ish) by applying `WordStartBefore` repeatedly.
    #[inline]
    pub fn word_backward(buffer: &TextBuffer, cursor: Pos, count: usize) -> Pos {
        apply_motion_n(buffer, cursor, Motion::WordStartBefore, count)
    }

    /// Move to the first line (`gg`).
    #[inline]
    pub fn gg(buffer: &TextBuffer, cursor: Pos) -> Pos {
        super::apply_motion(buffer, cursor, Motion::FileStart)
    }

    /// Move to the last line (`G`).
    #[inline]
    pub fn file_end(buffer: &TextBuffer, cursor: Pos) -> Pos {
        super::apply_motion(buffer, cursor, Motion::FileEnd)
    }
}

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

    #[test]
    fn motion_count_zero_is_noop() {
        let b = TextBuffer::from_str("abc\n");
        let p = Pos::new(0, 2);
        assert_eq!(apply_motion_n(&b, p, Motion::Left, 0), p);
        assert_eq!(apply_motion_n(&b, p, Motion::WordEndAfter, 0), p);
    }

    #[test]
    fn gg_goes_to_first_line_and_clamps_column() {
        let b = TextBuffer::from_str("a\nbb\nccc\n");
        let p = Pos::new(2, 2);
        let p2 = apply_motion(&b, p, Motion::FileStart);
        assert_eq!(p2.line, 0);
        // first line is "a" so col clamps to 1
        assert_eq!(p2.col, 1);
    }

    #[test]
    fn file_end_goes_to_last_line_and_clamps_column() {
        // NOTE: With a trailing '\n', Ropey reports an extra empty final line.
        // So "a\nbb\nccc\n" has 4 lines: "a", "bb", "ccc", "".
        let b = TextBuffer::from_str("a\nbb\nccc\n");
        let p = Pos::new(0, 10);
        let p2 = apply_motion(&b, p, Motion::FileEnd);

        // Last line is the empty line after the trailing newline.
        assert_eq!(p2.line, 3);
        assert_eq!(p2.col, 0);
    }

    #[test]
    fn line_start_and_line_end_work() {
        let b = TextBuffer::from_str("  hello\nworld!\n");
        let p = Pos::new(1, 2);

        let start = apply_motion(&b, p, Motion::LineStart);
        assert_eq!(start, Pos::new(1, 0));

        let end = apply_motion(&b, p, Motion::LineEnd);
        assert_eq!(end, Pos::new(1, 6));

        let first_non_whitespace = apply_motion(&b, Pos::new(0, 5), Motion::LineFirstNonWhitespace);
        assert_eq!(first_non_whitespace, Pos::new(0, 2));
    }

    #[test]
    fn first_non_whitespace_clamps_to_line_end_for_blank_lines() {
        let b = TextBuffer::from_str("   \n\t\t\n");

        assert_eq!(
            apply_motion(&b, Pos::new(0, 0), Motion::LineFirstNonWhitespace),
            Pos::new(0, 3)
        );
        assert_eq!(
            apply_motion(&b, Pos::new(1, 0), Motion::LineFirstNonWhitespace),
            Pos::new(1, 2)
        );
    }

    #[test]
    fn left_right_clamp_at_bounds() {
        // NOTE: With a trailing '\n', Ropey reports an extra empty final line.
        // Right at end-of-line can advance onto that empty line.
        let b = TextBuffer::from_str("ab\n");
        let p0 = Pos::new(0, 0);
        assert_eq!(apply_motion(&b, p0, Motion::Left), Pos::new(0, 0));

        // Moving right from end-of-line steps onto the newline, which maps to the next (empty) line at col 0.
        let p_end = Pos::new(0, 2);
        assert_eq!(apply_motion(&b, p_end, Motion::Right), Pos::new(1, 0));
    }

    #[test]
    fn up_down_preserve_column_when_possible() {
        let b = TextBuffer::from_str("aaaa\nb\ncccccc\n");
        let p = Pos::new(0, 3);

        let down = apply_motion(&b, p, Motion::Down);
        // line 1 is "b" so col clamps to 1
        assert_eq!(down, Pos::new(1, 1));

        let down2 = apply_motion(&b, down, Motion::Down);
        // line 2 is long enough, so col stays 1
        assert_eq!(down2, Pos::new(2, 1));

        let up = apply_motion(&b, down2, Motion::Up);
        assert_eq!(up, Pos::new(1, 1));
    }

    #[test]
    fn word_motions_ascii_smoke() {
        let b = TextBuffer::from_str("abc  def_12!\n");
        let p = Pos::new(0, 6); // in "def_12"

        let start = apply_motion(&b, p, Motion::WordStartBefore);
        assert_eq!(start, Pos::new(0, 5));

        let end = apply_motion(&b, start, Motion::WordEndAfter);
        assert_eq!(end, Pos::new(0, 10));
    }

    #[test]
    fn repeated_word_forward_stops_at_eof() {
        let b = TextBuffer::from_str("a b c\n");
        let p = Pos::new(0, 0);
        let p2 = apply_motion_n(&b, p, Motion::WordEndAfter, 100);

        // Vim's 'e' motion stops at the last character of the last word.
        assert_eq!(p2, Pos::new(0, 5));
    }

    #[test]
    fn word_start_after_visits_symbol_tokens() {
        let b = TextBuffer::from_str("(normal/insert/command)\n");
        let mut p = Pos::new(0, 0);

        p = apply_motion(&b, p, Motion::WordStartAfter);
        assert_eq!(p, Pos::new(0, 1)); // normal

        p = apply_motion(&b, p, Motion::WordStartAfter);
        assert_eq!(p, Pos::new(0, 7)); // /

        p = apply_motion(&b, p, Motion::WordStartAfter);
        assert_eq!(p, Pos::new(0, 8)); // insert

        p = apply_motion(&b, p, Motion::WordStartAfter);
        assert_eq!(p, Pos::new(0, 14)); // /

        p = apply_motion(&b, p, Motion::WordStartAfter);
        assert_eq!(p, Pos::new(0, 15)); // command

        p = apply_motion(&b, p, Motion::WordStartAfter);
        assert_eq!(p, Pos::new(0, 22)); // )
    }

    #[test]
    fn word_start_before_stops_on_symbol_token() {
        let b = TextBuffer::from_str("(normal/insert)\n");
        let p = Pos::new(0, 15); // after ')'
        let p2 = apply_motion(&b, p, Motion::WordStartBefore);
        assert_eq!(p2, Pos::new(0, 14)); // )
    }

    #[test]
    fn word_end_after_can_land_on_symbol_token() {
        let b = TextBuffer::from_str("alpha / beta\n");

        let p = Pos::new(0, 0);
        let p = apply_motion(&b, p, Motion::WordEndAfter);
        assert_eq!(p, Pos::new(0, 4)); // alpha

        let p = apply_motion(&b, p, Motion::WordEndAfter);
        assert_eq!(p, Pos::new(0, 6)); // /
    }

    #[test]
    fn find_and_till_char_stay_on_current_line() {
        let b = TextBuffer::from_str("alpha beta alpha\n");
        let cursor = Pos::new(0, 0);

        assert_eq!(
            apply_motion(&b, cursor, Motion::FindChar('b')),
            Pos::new(0, 6)
        );
        assert_eq!(
            apply_motion(&b, cursor, Motion::TillChar('b')),
            Pos::new(0, 5)
        );
        assert_eq!(
            apply_motion_n(&b, cursor, Motion::FindChar('a'), 2),
            Pos::new(0, 9)
        );
    }

    #[test]
    fn backward_find_and_till_char_stay_on_current_line() {
        let b = TextBuffer::from_str("alpha beta alpha\n");
        let cursor = Pos::new(0, 15);

        assert_eq!(
            apply_motion(&b, cursor, Motion::FindCharBefore('b')),
            Pos::new(0, 6)
        );
        assert_eq!(
            apply_motion(&b, cursor, Motion::TillCharBefore('b')),
            Pos::new(0, 7)
        );
        assert_eq!(
            apply_motion_n(&b, cursor, Motion::FindCharBefore('a'), 2),
            Pos::new(0, 9)
        );
    }

    #[test]
    fn match_delimiter_jumps_between_pair_endpoints() {
        let b = TextBuffer::from_str("fn main() {\n    call([x]);\n}\n");

        assert_eq!(
            apply_motion(&b, Pos::new(0, 7), Motion::MatchDelimiter),
            Pos::new(0, 8)
        );
        assert_eq!(
            apply_motion(&b, Pos::new(0, 8), Motion::MatchDelimiter),
            Pos::new(0, 7)
        );
        assert_eq!(
            apply_motion(&b, Pos::new(0, 10), Motion::MatchDelimiter),
            Pos::new(2, 0)
        );
        assert_eq!(
            apply_motion(&b, Pos::new(1, 9), Motion::MatchDelimiter),
            Pos::new(1, 11)
        );
    }

    #[test]
    fn match_delimiter_supports_symmetric_and_angle_delimiters() {
        let b = TextBuffer::from_str("let s = \"a \\\" b\"; let tag = <x>`tick`\n");

        assert_eq!(
            apply_motion(&b, Pos::new(0, 8), Motion::MatchDelimiter),
            Pos::new(0, 15)
        );
        assert_eq!(
            apply_motion(&b, Pos::new(0, 15), Motion::MatchDelimiter),
            Pos::new(0, 8)
        );
        assert_eq!(
            apply_motion(&b, Pos::new(0, 28), Motion::MatchDelimiter),
            Pos::new(0, 30)
        );
        assert_eq!(
            apply_motion(&b, Pos::new(0, 31), Motion::MatchDelimiter),
            Pos::new(0, 36)
        );
    }

    #[test]
    fn operator_find_char_includes_the_target_character() {
        let b = TextBuffer::from_str("alpha beta\n");
        let cursor = Pos::new(0, 0);

        assert_eq!(
            apply_motion_for_operator(&b, cursor, Motion::FindChar('b'), 1),
            Pos::new(0, 7)
        );
        assert_eq!(
            apply_motion_for_operator(&b, cursor, Motion::TillChar('b'), 1),
            Pos::new(0, 6)
        );
    }
}
