//! # [Ratatui] Constraint explorer example
//!
//! The latest version of this example is available in the [examples] folder in the repository.
//!
//! Please note that the examples are designed to be run against the `main` branch of the Github
//! repository. This means that you may not be able to compile with the latest release version on
//! crates.io, or the one that you have installed locally.
//!
//! See the [examples readme] for more information on finding examples that match the version of the
//! library you are using.
//!
//! [Ratatui]: https://github.com/ratatui-org/ratatui
//! [examples]: https://github.com/ratatui-org/ratatui/blob/main/examples
//! [examples readme]: https://github.com/ratatui-org/ratatui/blob/main/examples/README.md

use std::io::{self, stdout};

use color_eyre::{config::HookBuilder, Result};
use itertools::Itertools;
use ratatui::{
    backend::{Backend, CrosstermBackend},
    buffer::Buffer,
    crossterm::{
        event::{self, Event, KeyCode, KeyEventKind},
        terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
        ExecutableCommand,
    },
    layout::{
        Constraint::{self, Fill, Length, Max, Min, Percentage, Ratio},
        Flex, Layout, Rect,
    },
    style::{
        palette::tailwind::{BLUE, SKY, SLATE, STONE},
        Color, Style, Stylize,
    },
    symbols::{self, line},
    terminal::Terminal,
    text::{Line, Span, Text},
    widgets::{Block, Paragraph, Widget, Wrap},
};
use strum::{Display, EnumIter, FromRepr};

#[derive(Default)]
struct App {
    mode: AppMode,
    spacing: u16,
    constraints: Vec<Constraint>,
    selected_index: usize,
    value: u16,
}

#[derive(Debug, Default, PartialEq, Eq)]
enum AppMode {
    #[default]
    Running,
    Quit,
}

/// A variant of [`Constraint`] that can be rendered as a tab.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, EnumIter, FromRepr, Display)]
enum ConstraintName {
    #[default]
    Length,
    Percentage,
    Ratio,
    Min,
    Max,
    Fill,
}

/// A widget that renders a [`Constraint`] as a block. E.g.:
/// ```plain
/// ┌──────────────┐
/// │  Length(16)  │
/// │     16px     │
/// └──────────────┘
/// ```
struct ConstraintBlock {
    constraint: Constraint,
    legend: bool,
    selected: bool,
}

/// A widget that renders a spacer with a label indicating the width of the spacer. E.g.:
///
/// ```plain
/// ┌      ┐
///   8 px
/// └      ┘
/// ```
struct SpacerBlock;

fn main() -> Result<()> {
    init_error_hooks()?;
    let terminal = init_terminal()?;
    App::default().run(terminal)?;
    restore_terminal()?;
    Ok(())
}

// App behaviour
impl App {
    fn run(&mut self, mut terminal: Terminal<impl Backend>) -> Result<()> {
        self.insert_test_defaults();

        while self.is_running() {
            self.draw(&mut terminal)?;
            self.handle_events()?;
        }
        Ok(())
    }

    // TODO remove these - these are just for testing
    fn insert_test_defaults(&mut self) {
        self.constraints = vec![
            Constraint::Length(20),
            Constraint::Length(20),
            Constraint::Length(20),
        ];
        self.value = 20;
    }

    fn is_running(&self) -> bool {
        self.mode == AppMode::Running
    }

    fn draw(&self, terminal: &mut Terminal<impl Backend>) -> io::Result<()> {
        terminal.draw(|frame| frame.render_widget(self, frame.size()))?;
        Ok(())
    }

    fn handle_events(&mut self) -> Result<()> {
        match event::read()? {
            Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
                KeyCode::Char('q') | KeyCode::Esc => self.exit(),
                KeyCode::Char('1') => self.swap_constraint(ConstraintName::Min),
                KeyCode::Char('2') => self.swap_constraint(ConstraintName::Max),
                KeyCode::Char('3') => self.swap_constraint(ConstraintName::Length),
                KeyCode::Char('4') => self.swap_constraint(ConstraintName::Percentage),
                KeyCode::Char('5') => self.swap_constraint(ConstraintName::Ratio),
                KeyCode::Char('6') => self.swap_constraint(ConstraintName::Fill),
                KeyCode::Char('+') => self.increment_spacing(),
                KeyCode::Char('-') => self.decrement_spacing(),
                KeyCode::Char('x') => self.delete_block(),
                KeyCode::Char('a') => self.insert_block(),
                KeyCode::Char('k') | KeyCode::Up => self.increment_value(),
                KeyCode::Char('j') | KeyCode::Down => self.decrement_value(),
                KeyCode::Char('h') | KeyCode::Left => self.prev_block(),
                KeyCode::Char('l') | KeyCode::Right => self.next_block(),
                _ => {}
            },
            _ => {}
        }
        Ok(())
    }

    fn increment_value(&mut self) {
        let Some(constraint) = self.constraints.get_mut(self.selected_index) else {
            return;
        };
        match constraint {
            Constraint::Length(v)
            | Constraint::Min(v)
            | Constraint::Max(v)
            | Constraint::Fill(v)
            | Constraint::Percentage(v) => *v = v.saturating_add(1),
            Constraint::Ratio(_n, d) => *d = d.saturating_add(1),
        };
    }

    fn decrement_value(&mut self) {
        let Some(constraint) = self.constraints.get_mut(self.selected_index) else {
            return;
        };
        match constraint {
            Constraint::Length(v)
            | Constraint::Min(v)
            | Constraint::Max(v)
            | Constraint::Fill(v)
            | Constraint::Percentage(v) => *v = v.saturating_sub(1),
            Constraint::Ratio(_n, d) => *d = d.saturating_sub(1),
        };
    }

    /// select the next block with wrap around
    fn next_block(&mut self) {
        if self.constraints.is_empty() {
            return;
        }
        let len = self.constraints.len();
        self.selected_index = (self.selected_index + 1) % len;
    }

    /// select the previous block with wrap around
    fn prev_block(&mut self) {
        if self.constraints.is_empty() {
            return;
        }
        let len = self.constraints.len();
        self.selected_index = (self.selected_index + self.constraints.len() - 1) % len;
    }

    /// delete the selected block
    fn delete_block(&mut self) {
        if self.constraints.is_empty() {
            return;
        }
        self.constraints.remove(self.selected_index);
        self.selected_index = self.selected_index.saturating_sub(1);
    }

    /// insert a block after the selected block
    fn insert_block(&mut self) {
        let index = self
            .selected_index
            .saturating_add(1)
            .min(self.constraints.len());
        let constraint = Constraint::Length(self.value);
        self.constraints.insert(index, constraint);
        self.selected_index = index;
    }

    fn increment_spacing(&mut self) {
        self.spacing = self.spacing.saturating_add(1);
    }

    fn decrement_spacing(&mut self) {
        self.spacing = self.spacing.saturating_sub(1);
    }

    fn exit(&mut self) {
        self.mode = AppMode::Quit;
    }

    fn swap_constraint(&mut self, name: ConstraintName) {
        if self.constraints.is_empty() {
            return;
        }
        let constraint = match name {
            ConstraintName::Length => Length(self.value),
            ConstraintName::Percentage => Percentage(self.value),
            ConstraintName::Min => Min(self.value),
            ConstraintName::Max => Max(self.value),
            ConstraintName::Fill => Fill(self.value),
            ConstraintName::Ratio => Ratio(1, u32::from(self.value) / 4), // for balance
        };
        self.constraints[self.selected_index] = constraint;
    }
}

impl From<Constraint> for ConstraintName {
    fn from(constraint: Constraint) -> Self {
        match constraint {
            Length(_) => Self::Length,
            Percentage(_) => Self::Percentage,
            Ratio(_, _) => Self::Ratio,
            Min(_) => Self::Min,
            Max(_) => Self::Max,
            Fill(_) => Self::Fill,
        }
    }
}

impl Widget for &App {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let [header_area, instructions_area, swap_legend_area, _, blocks_area] =
            Layout::vertical([
                Length(2), // header
                Length(2), // instructions
                Length(1), // swap key legend
                Length(1), // gap
                Fill(1),   // blocks
            ])
            .areas(area);

        App::header().render(header_area, buf);
        App::instructions().render(instructions_area, buf);
        App::swap_legend().render(swap_legend_area, buf);
        self.render_layout_blocks(blocks_area, buf);
    }
}

// App rendering
impl App {
    const HEADER_COLOR: Color = SLATE.c200;
    const TEXT_COLOR: Color = SLATE.c400;
    const AXIS_COLOR: Color = SLATE.c500;

    fn header() -> impl Widget {
        let text = "Constraint Explorer";
        text.bold().fg(Self::HEADER_COLOR).into_centered_line()
    }

    fn instructions() -> impl Widget {
        let text = "◄ ►: select, ▲ ▼: edit, 1-6: swap, a: add, x: delete, q: quit, + -: spacing";
        Paragraph::new(text)
            .fg(Self::TEXT_COLOR)
            .centered()
            .wrap(Wrap { trim: false })
    }

    fn swap_legend() -> impl Widget {
        #[allow(unstable_name_collisions)]
        Paragraph::new(
            Line::from(
                [
                    ConstraintName::Min,
                    ConstraintName::Max,
                    ConstraintName::Length,
                    ConstraintName::Percentage,
                    ConstraintName::Ratio,
                    ConstraintName::Fill,
                ]
                .iter()
                .enumerate()
                .map(|(i, name)| {
                    format!("  {i}: {name}  ", i = i + 1)
                        .fg(SLATE.c200)
                        .bg(name.color())
                })
                .intersperse(Span::from(" "))
                .collect_vec(),
            )
            .centered(),
        )
        .wrap(Wrap { trim: false })
    }

    /// A bar like `<----- 80 px (gap: 2 px) ----->`
    ///
    /// Only shows the gap when spacing is not zero
    fn axis(&self, width: u16) -> impl Widget {
        let label = if self.spacing != 0 {
            format!("{} px (gap: {} px)", width, self.spacing)
        } else {
            format!("{width} px")
        };
        let bar_width = width.saturating_sub(2) as usize; // we want to `<` and `>` at the ends
        let width_bar = format!("<{label:-^bar_width$}>");
        Paragraph::new(width_bar).fg(Self::AXIS_COLOR).centered()
    }

    fn render_layout_blocks(&self, area: Rect, buf: &mut Buffer) {
        let [user_constraints, area] = Layout::vertical([Length(3), Fill(1)])
            .spacing(1)
            .areas(area);

        self.render_user_constraints_legend(user_constraints, buf);

        let [start, center, end, space_around, space_between] =
            Layout::vertical([Length(7); 5]).areas(area);

        self.render_layout_block(Flex::Start, start, buf);
        self.render_layout_block(Flex::Center, center, buf);
        self.render_layout_block(Flex::End, end, buf);
        self.render_layout_block(Flex::SpaceAround, space_around, buf);
        self.render_layout_block(Flex::SpaceBetween, space_between, buf);
    }

    fn render_user_constraints_legend(&self, area: Rect, buf: &mut Buffer) {
        let blocks = Layout::horizontal(
            self.constraints
                .iter()
                .map(|_| Constraint::Fill(1))
                .collect_vec(),
        )
        .split(area);

        for (i, (area, constraint)) in blocks.iter().zip(self.constraints.iter()).enumerate() {
            let selected = self.selected_index == i;
            ConstraintBlock::new(*constraint, selected, true).render(*area, buf);
        }
    }

    fn render_layout_block(&self, flex: Flex, area: Rect, buf: &mut Buffer) {
        let [label_area, axis_area, blocks_area] =
            Layout::vertical([Length(1), Max(1), Length(4)]).areas(area);

        if label_area.height > 0 {
            format!("Flex::{flex:?}").bold().render(label_area, buf);
        }

        self.axis(area.width).render(axis_area, buf);

        let (blocks, spacers) = Layout::horizontal(&self.constraints)
            .flex(flex)
            .spacing(self.spacing)
            .split_with_spacers(blocks_area);

        for (i, (area, constraint)) in blocks.iter().zip(self.constraints.iter()).enumerate() {
            let selected = self.selected_index == i;
            ConstraintBlock::new(*constraint, selected, false).render(*area, buf);
        }

        for area in spacers.iter() {
            SpacerBlock.render(*area, buf);
        }
    }
}

impl Widget for ConstraintBlock {
    fn render(self, area: Rect, buf: &mut Buffer) {
        match area.height {
            1 => self.render_1px(area, buf),
            2 => self.render_2px(area, buf),
            _ => self.render_4px(area, buf),
        }
    }
}

impl ConstraintBlock {
    const TEXT_COLOR: Color = SLATE.c200;

    const fn new(constraint: Constraint, selected: bool, legend: bool) -> Self {
        Self {
            constraint,
            legend,
            selected,
        }
    }

    fn label(&self, width: u16) -> String {
        let long_width = format!("{width} px");
        let short_width = format!("{width}");
        // border takes up 2 columns
        let available_space = width.saturating_sub(2) as usize;
        let width_label = if long_width.len() < available_space {
            long_width
        } else if short_width.len() < available_space {
            short_width
        } else {
            String::new()
        };
        format!("{}\n{}", self.constraint, width_label)
    }

    fn render_1px(&self, area: Rect, buf: &mut Buffer) {
        let lighter_color = ConstraintName::from(self.constraint).lighter_color();
        let main_color = ConstraintName::from(self.constraint).color();
        let selected_color = if self.selected {
            lighter_color
        } else {
            main_color
        };
        Block::new()
            .fg(Self::TEXT_COLOR)
            .bg(selected_color)
            .render(area, buf);
    }

    fn render_2px(&self, area: Rect, buf: &mut Buffer) {
        let lighter_color = ConstraintName::from(self.constraint).lighter_color();
        let main_color = ConstraintName::from(self.constraint).color();
        let selected_color = if self.selected {
            lighter_color
        } else {
            main_color
        };
        Block::bordered()
            .border_set(symbols::border::QUADRANT_OUTSIDE)
            .border_style(Style::reset().fg(selected_color).reversed())
            .render(area, buf);
    }

    fn render_4px(&self, area: Rect, buf: &mut Buffer) {
        let lighter_color = ConstraintName::from(self.constraint).lighter_color();
        let main_color = ConstraintName::from(self.constraint).color();
        let selected_color = if self.selected {
            lighter_color
        } else {
            main_color
        };
        let color = if self.legend {
            selected_color
        } else {
            main_color
        };
        let label = self.label(area.width);
        let block = Block::bordered()
            .border_set(symbols::border::QUADRANT_OUTSIDE)
            .border_style(Style::reset().fg(color).reversed())
            .fg(Self::TEXT_COLOR)
            .bg(color);
        Paragraph::new(label)
            .centered()
            .fg(Self::TEXT_COLOR)
            .bg(color)
            .block(block)
            .render(area, buf);

        if !self.legend {
            let border_color = if self.selected {
                lighter_color
            } else {
                main_color
            };
            if let Some(last_row) = area.rows().last() {
                buf.set_style(last_row, border_color);
            }
        }
    }
}

impl Widget for SpacerBlock {
    fn render(self, area: Rect, buf: &mut Buffer) {
        match area.height {
            1 => (),
            2 => Self::render_2px(area, buf),
            3 => Self::render_3px(area, buf),
            _ => Self::render_4px(area, buf),
        }
    }
}

impl SpacerBlock {
    const TEXT_COLOR: Color = SLATE.c500;
    const BORDER_COLOR: Color = SLATE.c600;

    /// A block with a corner borders
    fn block() -> impl Widget {
        let corners_only = symbols::border::Set {
            top_left: line::NORMAL.top_left,
            top_right: line::NORMAL.top_right,
            bottom_left: line::NORMAL.bottom_left,
            bottom_right: line::NORMAL.bottom_right,
            vertical_left: " ",
            vertical_right: " ",
            horizontal_top: " ",
            horizontal_bottom: " ",
        };
        Block::bordered()
            .border_set(corners_only)
            .border_style(Self::BORDER_COLOR)
    }

    /// A vertical line used if there is not enough space to render the block
    fn line() -> impl Widget {
        Paragraph::new(Text::from(vec![
            Line::from(""),
            Line::from("│"),
            Line::from("│"),
            Line::from(""),
        ]))
        .style(Self::BORDER_COLOR)
    }

    /// A label that says "Spacer" if there is enough space
    fn spacer_label(width: u16) -> impl Widget {
        let label = if width >= 6 { "Spacer" } else { "" };
        label.fg(Self::TEXT_COLOR).into_centered_line()
    }

    /// A label that says "8 px" if there is enough space
    fn label(width: u16) -> impl Widget {
        let long_label = format!("{width} px");
        let short_label = format!("{width}");
        let label = if long_label.len() < width as usize {
            long_label
        } else if short_label.len() < width as usize {
            short_label
        } else {
            String::new()
        };
        Line::styled(label, Self::TEXT_COLOR).centered()
    }

    fn render_2px(area: Rect, buf: &mut Buffer) {
        if area.width > 1 {
            Self::block().render(area, buf);
        } else {
            Self::line().render(area, buf);
        }
    }

    fn render_3px(area: Rect, buf: &mut Buffer) {
        if area.width > 1 {
            Self::block().render(area, buf);
        } else {
            Self::line().render(area, buf);
        }

        let row = area.rows().nth(1).unwrap_or_default();
        Self::spacer_label(area.width).render(row, buf);
    }

    fn render_4px(area: Rect, buf: &mut Buffer) {
        if area.width > 1 {
            Self::block().render(area, buf);
        } else {
            Self::line().render(area, buf);
        }

        let row = area.rows().nth(1).unwrap_or_default();
        Self::spacer_label(area.width).render(row, buf);

        let row = area.rows().nth(2).unwrap_or_default();
        Self::label(area.width).render(row, buf);
    }
}

impl ConstraintName {
    const fn color(self) -> Color {
        match self {
            Self::Length => SLATE.c700,
            Self::Percentage => SLATE.c800,
            Self::Ratio => SLATE.c900,
            Self::Fill => SLATE.c950,
            Self::Min => BLUE.c800,
            Self::Max => BLUE.c900,
        }
    }

    const fn lighter_color(self) -> Color {
        match self {
            Self::Length => STONE.c500,
            Self::Percentage => STONE.c600,
            Self::Ratio => STONE.c700,
            Self::Fill => STONE.c800,
            Self::Min => SKY.c600,
            Self::Max => SKY.c700,
        }
    }
}

fn init_error_hooks() -> Result<()> {
    let (panic, error) = HookBuilder::default().into_hooks();
    let panic = panic.into_panic_hook();
    let error = error.into_eyre_hook();
    color_eyre::eyre::set_hook(Box::new(move |e| {
        let _ = restore_terminal();
        error(e)
    }))?;
    std::panic::set_hook(Box::new(move |info| {
        let _ = restore_terminal();
        panic(info);
    }));
    Ok(())
}

fn init_terminal() -> Result<Terminal<impl Backend>> {
    enable_raw_mode()?;
    stdout().execute(EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout());
    let terminal = Terminal::new(backend)?;
    Ok(terminal)
}

fn restore_terminal() -> Result<()> {
    disable_raw_mode()?;
    stdout().execute(LeaveAlternateScreen)?;
    Ok(())
}
