#![forbid(unsafe_code)]

//! Spinner widget.

use crate::block::Block;
use crate::{StatefulWidget, Widget, clear_text_area};
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_text::display_width;

/// Braille dot spinner animation frames.
pub const DOTS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/// ASCII line spinner animation frames.
pub const LINE: &[&str] = &["|", "/", "-", "\\"];

/// A widget to display a spinner.
#[derive(Debug, Clone, Default)]
pub struct Spinner<'a> {
    block: Option<Block<'a>>,
    style: Style,
    frames: &'a [&'a str],
    label: Option<&'a str>,
}

impl<'a> Spinner<'a> {
    /// Create a new spinner with default dot frames.
    pub fn new() -> Self {
        Self {
            block: None,
            style: Style::default(),
            frames: DOTS,
            label: None,
        }
    }

    /// Wrap the spinner in a [`Block`] container.
    #[must_use]
    pub fn block(mut self, block: Block<'a>) -> Self {
        self.block = Some(block);
        self
    }

    /// Set the base style for the spinner.
    #[must_use]
    pub fn style(mut self, style: Style) -> Self {
        self.style = style;
        self
    }

    /// Set the animation frame characters.
    #[must_use]
    pub fn frames(mut self, frames: &'a [&'a str]) -> Self {
        self.frames = frames;
        self
    }

    /// Set a text label displayed next to the spinner.
    #[must_use]
    pub fn label(mut self, label: &'a str) -> Self {
        self.label = Some(label);
        self
    }

    fn frame_for_render(&self, current_frame: usize, use_unicode: bool) -> Option<&'a str> {
        if self.frames.is_empty() {
            return None;
        }

        let frame_idx = current_frame % self.frames.len();
        if use_unicode {
            return Some(self.frames[frame_idx]);
        }

        let candidate = self.frames[frame_idx];
        if candidate.is_ascii() {
            Some(candidate)
        } else {
            self.frames
                .iter()
                .copied()
                .find(|frame| frame.is_ascii())
                .or(Some("*"))
        }
    }
}

/// Mutable state for a [`Spinner`] widget.
#[derive(Debug, Clone, Default)]
pub struct SpinnerState {
    /// Index of the currently displayed animation frame.
    pub current_frame: usize,
}

impl SpinnerState {
    /// Advance to the next animation frame.
    pub fn tick(&mut self) {
        self.current_frame = self.current_frame.wrapping_add(1);
    }
}

impl<'a> StatefulWidget for Spinner<'a> {
    type State = SpinnerState;

    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
        #[cfg(feature = "tracing")]
        let _span = tracing::debug_span!(
            "widget_render",
            widget = "Spinner",
            x = area.x,
            y = area.y,
            w = area.width,
            h = area.height
        )
        .entered();

        let deg = frame.buffer.degradation;

        // Skeleton+: skip entirely (spinner is decorative)
        if !deg.render_content() {
            clear_text_area(frame, area, Style::default());
            return;
        }

        // EssentialOnly: spinner is decorative, only show label text
        if !deg.render_decorative() {
            clear_text_area(frame, area, Style::default());
            if let Some(label) = self.label {
                crate::draw_text_span(frame, area.x, area.y, label, Style::default(), area.right());
            }
            return;
        }

        let style = if deg.apply_styling() {
            self.style
        } else {
            Style::default()
        };

        clear_text_area(frame, area, style);

        let spinner_area = match &self.block {
            Some(b) => {
                b.render(area, frame);
                b.inner(area)
            }
            None => area,
        };

        if spinner_area.is_empty() {
            return;
        }

        let mut x = spinner_area.left();
        let y = spinner_area.top();
        if let Some(frame_char) =
            self.frame_for_render(state.current_frame, deg.use_unicode_borders())
        {
            crate::draw_text_span(frame, x, y, frame_char, style, spinner_area.right());
            let w = display_width(frame_char);
            x += w as u16;
        }

        // Render label
        if let Some(label) = self.label {
            if x > spinner_area.left() {
                x += 1;
            }
            if x < spinner_area.right() {
                crate::draw_text_span(frame, x, y, label, style, spinner_area.right());
            }
        }
    }
}

impl<'a> Widget for Spinner<'a> {
    fn render(&self, area: Rect, frame: &mut Frame) {
        let mut state = SpinnerState::default();
        StatefulWidget::render(self, area, frame, &mut state);
    }
}

// ============================================================================
// Accessibility
// ============================================================================

impl ftui_a11y::Accessible for Spinner<'_> {
    fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
        use ftui_a11y::node::{A11yNodeInfo, A11yRole, A11yState};

        let id = crate::a11y_node_id(area);
        let name = self
            .label
            .map(|l| format!("Loading: {l}"))
            .unwrap_or_else(|| "Loading...".to_owned());
        let node = A11yNodeInfo::new(id, A11yRole::ProgressBar, area)
            .with_name(name)
            .with_state(A11yState {
                busy: true,
                ..A11yState::default()
            });
        vec![node]
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ftui_render::buffer::Buffer;
    use ftui_render::grapheme_pool::GraphemePool;

    fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
        buf.get(x, y).and_then(|c| c.content.as_char())
    }

    fn raw_row_text(buf: &Buffer, y: u16, width: u16) -> String {
        (0..width)
            .map(|x| {
                buf.get(x, y)
                    .and_then(|c| c.content.as_char())
                    .unwrap_or(' ')
            })
            .collect()
    }

    // --- SpinnerState tests ---

    #[test]
    fn state_default() {
        let state = SpinnerState::default();
        assert_eq!(state.current_frame, 0);
    }

    #[test]
    fn state_tick_increments() {
        let mut state = SpinnerState::default();
        state.tick();
        assert_eq!(state.current_frame, 1);
        state.tick();
        assert_eq!(state.current_frame, 2);
    }

    #[test]
    fn state_tick_wraps_on_overflow() {
        let mut state = SpinnerState {
            current_frame: usize::MAX,
        };
        state.tick();
        assert_eq!(state.current_frame, 0);
    }

    // --- Builder tests ---

    #[test]
    fn default_uses_dots_frames() {
        let spinner = Spinner::new();
        assert_eq!(spinner.frames.len(), DOTS.len());
        assert_eq!(spinner.frames, DOTS);
    }

    #[test]
    fn custom_frames() {
        let frames: &[&str] = &["A", "B", "C"];
        let spinner = Spinner::new().frames(frames);
        assert_eq!(spinner.frames.len(), 3);
    }

    #[test]
    fn builder_label() {
        let spinner = Spinner::new().label("Loading...");
        assert_eq!(spinner.label, Some("Loading..."));
    }

    // --- Rendering tests ---

    #[test]
    fn render_zero_area() {
        let spinner = Spinner::new();
        let area = Rect::new(0, 0, 0, 0);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(1, 1, &mut pool);
        Widget::render(&spinner, area, &mut frame);
        // Should not panic
    }

    #[test]
    fn stateless_render_uses_frame_zero() {
        let frames: &[&str] = &["A", "B", "C"];
        let spinner = Spinner::new().frames(frames);
        let area = Rect::new(0, 0, 5, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        Widget::render(&spinner, area, &mut frame);

        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
    }

    #[test]
    fn stateful_render_cycles_frames() {
        let frames: &[&str] = &["X", "Y", "Z"];
        let spinner = Spinner::new().frames(frames);
        let area = Rect::new(0, 0, 5, 1);

        // Frame 0 -> "X"
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        let mut state = SpinnerState { current_frame: 0 };
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('X'));

        // Frame 1 -> "Y"
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        state.current_frame = 1;
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('Y'));

        // Frame 2 -> "Z"
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        state.current_frame = 2;
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('Z'));

        // Frame 3 wraps -> "X"
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        state.current_frame = 3;
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('X'));
    }

    #[test]
    fn render_with_label() {
        let frames: &[&str] = &["*"];
        let spinner = Spinner::new().frames(frames).label("Go");
        let area = Rect::new(0, 0, 10, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        let mut state = SpinnerState::default();
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        // "*" at x=0, then space, then "Go" at x=2
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('*'));
        assert_eq!(cell_char(&frame.buffer, 2, 0), Some('G'));
        assert_eq!(cell_char(&frame.buffer, 3, 0), Some('o'));
    }

    #[test]
    fn render_with_block() {
        let frames: &[&str] = &["!"];
        let spinner = Spinner::new().frames(frames).block(Block::bordered());
        // Block::bordered() includes 1 cell padding on each side, so we need
        // at least 5 rows to have a 1-row inner content area.
        let area = Rect::new(0, 0, 10, 5);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 5, &mut pool);
        let mut state = SpinnerState::default();
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        // Inside the border + padding at (2, 2)
        assert_eq!(cell_char(&frame.buffer, 2, 2), Some('!'));
    }

    #[test]
    fn render_line_frames() {
        let spinner = Spinner::new().frames(LINE);
        let area = Rect::new(0, 0, 5, 1);

        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        let mut state = SpinnerState { current_frame: 0 };
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('|'));

        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        state.current_frame = 1;
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('/'));
    }

    #[test]
    fn large_frame_index_wraps_correctly() {
        let frames: &[&str] = &["A", "B"];
        let spinner = Spinner::new().frames(frames);
        let area = Rect::new(0, 0, 5, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        let mut state = SpinnerState {
            current_frame: 1000,
        };
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
        // 1000 % 2 = 0 -> "A"
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
    }

    #[test]
    fn dots_frame_set_has_expected_length() {
        assert_eq!(DOTS.len(), 10);
    }

    #[test]
    fn line_frame_set_has_expected_length() {
        assert_eq!(LINE.len(), 4);
    }

    // --- Degradation tests ---

    #[test]
    fn degradation_skeleton_skips_entirely() {
        use ftui_render::budget::DegradationLevel;

        let frames: &[&str] = &["*"];
        let spinner = Spinner::new().frames(frames).label("Loading");
        let area = Rect::new(0, 0, 10, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        let mut state = SpinnerState::default();
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        frame.buffer.degradation = DegradationLevel::Skeleton;
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        assert_eq!(raw_row_text(&frame.buffer, 0, 10), "          ");
    }

    #[test]
    fn degradation_essential_only_shows_label_only() {
        use ftui_render::budget::DegradationLevel;

        let frames: &[&str] = &["*"];
        let spinner = Spinner::new().frames(frames).label("Go");
        let area = Rect::new(0, 0, 10, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        frame.buffer.degradation = DegradationLevel::EssentialOnly;
        let mut state = SpinnerState::default();
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        // Label "Go" rendered, no spinner frame
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('G'));
        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('o'));
    }

    #[test]
    fn degradation_simple_borders_uses_ascii_fallback() {
        use ftui_render::budget::DegradationLevel;

        // Use Unicode frames that should fall back to ASCII
        let spinner = Spinner::new(); // default DOTS frames are Unicode
        let area = Rect::new(0, 0, 5, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        frame.buffer.degradation = DegradationLevel::SimpleBorders;
        let mut state = SpinnerState::default();
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        // Should use "*" fallback since DOTS are non-ASCII
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('*'));
    }

    #[test]
    fn degradation_full_uses_unicode_frames() {
        use ftui_render::budget::DegradationLevel;

        let spinner = Spinner::new(); // DOTS frames
        let area = Rect::new(0, 0, 5, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        frame.buffer.degradation = DegradationLevel::Full;
        let mut state = SpinnerState::default();
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        // Should use the first DOTS frame '⠋'
        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('⠋'));
    }

    #[test]
    fn degradation_ascii_fallback_prefers_available_ascii_frame() {
        use ftui_render::budget::DegradationLevel;

        let frames: &[&str] = &["⠋", "-", "\\"];
        let spinner = Spinner::new().frames(frames);
        let area = Rect::new(0, 0, 5, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(5, 1, &mut pool);
        frame.buffer.degradation = DegradationLevel::SimpleBorders;
        let mut state = SpinnerState { current_frame: 0 };
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('-'));
    }

    #[test]
    fn empty_frames_still_render_label() {
        let frames: &[&str] = &[];
        let spinner = Spinner::new().frames(frames).label("Loading");
        let area = Rect::new(0, 0, 10, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        let mut state = SpinnerState::default();
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        assert_eq!(cell_char(&frame.buffer, 0, 0), Some('L'));
        assert_eq!(cell_char(&frame.buffer, 1, 0), Some('o'));
    }

    #[test]
    fn render_shorter_label_clears_stale_suffix() {
        let frames: &[&str] = &["*"];
        let area = Rect::new(0, 0, 10, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        let mut state = SpinnerState::default();

        StatefulWidget::render(
            &Spinner::new().frames(frames).label("Loading"),
            area,
            &mut frame,
            &mut state,
        );
        StatefulWidget::render(
            &Spinner::new().frames(frames).label("Go"),
            area,
            &mut frame,
            &mut state,
        );

        assert_eq!(raw_row_text(&frame.buffer, 0, 10), "* Go      ");
    }

    #[test]
    fn degradation_essential_only_clears_previous_spinner_frame() {
        use ftui_render::budget::DegradationLevel;

        let frames: &[&str] = &["*"];
        let spinner = Spinner::new().frames(frames).label("Go");
        let area = Rect::new(0, 0, 10, 1);
        let mut pool = GraphemePool::new();
        let mut frame = Frame::new(10, 1, &mut pool);
        let mut state = SpinnerState::default();

        StatefulWidget::render(&spinner, area, &mut frame, &mut state);
        frame.buffer.degradation = DegradationLevel::EssentialOnly;
        StatefulWidget::render(&spinner, area, &mut frame, &mut state);

        assert_eq!(raw_row_text(&frame.buffer, 0, 10), "Go        ");
    }
}
