// SPDX-License-Identifier: CC-BY-4.0

use crate::args::MENU_HELP;
use crate::config;
use crate::formats;
use crate::match_system::{Directory, File, Matches};
use crate::writer;
use crossterm::{
    cursor,
    event::{self, Event, KeyCode, KeyEvent},
    execute, queue,
    style::{self, Print, SetBackgroundColor},
    terminal::{self, ClearType},
};
use std::ffi::OsString;
use std::io::{self, StdoutLock, Write};
use std::process::Command;

const START_X: u16 = formats::SELECTED_INDICATOR.len() as u16;
const START_Y: u16 = 0;

struct PathInfo {
    paths: Vec<usize>,
    prev: usize,
    next: usize,
    passed: bool,
}

impl PathInfo {
    pub fn new(paths: Vec<usize>) -> PathInfo {
        PathInfo {
            paths,
            prev: 0,
            next: 1,
            passed: false,
        }
    }

    pub fn top(&mut self) {
        self.prev = 0;
        self.next = 1;
        self.passed = false;
    }

    pub fn bottom(&mut self) {
        let last_match_is_path = config().just_files as usize;
        self.prev = self.paths.len() - 1 - last_match_is_path;
        self.next = self.paths.len() - last_match_is_path;
        self.passed = false;
    }

    pub fn down(&mut self, selected_id: usize) {
        if self.passed {
            self.prev += 1;
            self.passed = false;
        }
        if self.next != self.paths.len() && selected_id == *self.paths.get(self.next).unwrap() {
            self.next += 1;
            self.passed = true;
        }
    }

    pub fn up(&mut self, selected_id: usize) {
        if self.passed {
            self.next -= 1;
            self.passed = false;
        }
        if self.prev != 0 && selected_id == *self.paths.get(self.prev).unwrap() {
            self.prev -= 1;
            self.passed = true;
        }
    }

    pub fn dist_down(&self, selected_id: usize) -> u16 {
        if self.next == self.paths.len() {
            return 0;
        }
        (*self.paths.get(self.next).unwrap() - selected_id) as u16
    }

    pub fn dist_up(&self, selected_id: usize) -> u16 {
        (selected_id - *self.paths.get(self.prev).unwrap()) as u16
    }
}

pub struct Menu<'a, 'b> {
    pi: PathInfo,
    selected_id: usize,
    cursor_y: u16,
    out: &'a mut StdoutLock<'b>,
    searched: &'a Matches,
    lines: Vec<String>,
    height: u16,
    width: u16,
    colors: bool,
    scroll_offset: u16,
    big_jump: u16,
    small_jump: u16,
    help_popup_open: bool,
}

impl<'a, 'b> Menu<'a, 'b> {
    fn new(out: &'a mut StdoutLock<'b>, searched: &'a Matches) -> io::Result<Menu<'a, 'b>> {
        let mut buffer: Vec<u8> = Vec::new();
        let mut path_ids: Vec<usize> = Vec::new();
        writer::write_results(&mut buffer, &searched, Some(&mut path_ids))?;
        let lines: Vec<String> = buffer
            .split(|&byte| byte == formats::NEW_LINE as u8)
            .map(|v| String::from_utf8_lossy(v).into())
            .collect();

        let (width, height) = terminal::size()?;
        let (scroll_offset, big_jump, small_jump) = Menu::scroll_info(height);

        Ok(Menu {
            selected_id: 0,
            cursor_y: 0,
            out,
            searched,
            lines,
            colors: config().colors,
            pi: PathInfo::new(path_ids),
            height,
            width,
            scroll_offset,
            big_jump,
            small_jump,
            help_popup_open: false,
        })
    }

    fn page_jump_len(&self) -> u16 {
        self.height
    }

    fn lines_below_cursor(&self) -> usize {
        (self.height - self.cursor_y - 1) as usize
    }

    fn down_page(&mut self) -> io::Result<()> {
        if self.selected_id + self.lines_below_cursor() as usize >= self.max_line_id() {
            return Ok(());
        }
        let dist = (self.page_jump_len() as usize)
            .min(self.max_line_id() as usize - self.selected_id - self.lines_below_cursor());
        for i in 1..=dist {
            self.pi.down(self.selected_id + i as usize);
        }
        self.selected_id += dist;
        self.draw()
    }

    fn up_page(&mut self) -> io::Result<()> {
        if self.cursor_y as usize == self.selected_id {
            return Ok(());
        }
        let dist = (self.page_jump_len() as usize).min(self.selected_id - self.cursor_y as usize);
        for i in 1..=dist {
            self.pi.up(self.selected_id - i as usize);
        }
        self.selected_id = self.selected_id - dist as usize;
        self.draw()
    }

    fn set_dims(&mut self, dims: (u16, u16)) {
        (self.width, self.height) = dims;
        (self.scroll_offset, self.big_jump, self.small_jump) = Menu::scroll_info(self.height);
    }

    fn scroll_info(num_rows: u16) -> (u16, u16, u16) {
        let scroll_offset = num_rows / 5;
        let big_jump = scroll_offset;
        let small_jump = 1;
        (scroll_offset, big_jump, small_jump)
    }

    pub fn enter(out: &'a mut StdoutLock<'b>, matches: Matches) -> io::Result<()> {
        let mut menu: Menu = Menu::new(out, &matches)?;

        menu.claim_term()?;
        menu.draw()?;

        loop {
            let event = event::read();
            if let Ok(Event::Key(KeyEvent {
                code,
                modifiers,
                kind: crossterm::event::KeyEventKind::Press,
                ..
            })) = event
            {
                if !menu.help_popup_open {
                    match code {
                        KeyCode::Char('j') | KeyCode::Char('n') | KeyCode::Down => {
                            menu.down(menu.small_jump)?
                        }
                        KeyCode::Char('k') | KeyCode::Char('p') | KeyCode::Up => {
                            menu.up(menu.small_jump)?
                        }
                        KeyCode::Char('J') | KeyCode::Char('N') => menu.down(menu.big_jump)?,
                        KeyCode::Char('h') => {
                            if !menu.help_popup_open {
                                menu.help_popup()?;
                            }
                        }
                        KeyCode::Char('K') | KeyCode::Char('P') => menu.up(menu.big_jump)?,
                        KeyCode::Char('}') | KeyCode::Char(']') => menu.down_path()?,
                        KeyCode::Char('{') | KeyCode::Char('[') => menu.up_path()?,
                        KeyCode::Char('G') | KeyCode::Char('>') | KeyCode::End => menu.bottom()?,
                        KeyCode::Char('g') | KeyCode::Char('<') | KeyCode::Home => menu.top()?,
                        KeyCode::Char('f') | KeyCode::PageDown => {
                            menu.down_page()?;
                        }
                        KeyCode::Char('b') | KeyCode::PageUp => {
                            menu.up_page()?;
                        }
                        KeyCode::Enter => {
                            let match_info = MatchInfo::find(menu.selected_id, &menu.searched);
                            let path = config().path.join(match_info.path);

                            return menu.exit_and_open(
                                path.as_os_str().to_os_string(),
                                match_info.line_num,
                            );
                        }
                        _ => {}
                    }
                }
                match code {
                    KeyCode::Char('q') => {
                        if menu.help_popup_open {
                            menu.help_popup_open = false;
                            menu.draw()?;
                        } else {
                            break;
                        }
                    }
                    KeyCode::Char('z') => {
                        if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
                            menu.suspend()?;
                            menu.resume()?;
                        }
                    }
                    KeyCode::Char('c') => {
                        if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
                            break;
                        }
                    }
                    _ => {}
                }
            } else if let Ok(Event::Resize(new_width, new_height)) = event {
                if menu.height != new_height || menu.width != new_width {
                    menu.resize(new_height, new_width)?;
                }
            }
        }
        menu.give_up_term()
    }

    fn resize(&mut self, new_height: u16, new_width: u16) -> io::Result<()> {
        self.set_dims((new_width, new_height));
        if self.selected_id > (self.height / 2) as usize {
            self.cursor_y = self.height / 2;
        }
        self.draw()
    }

    fn draw(&mut self) -> io::Result<()> {
        queue!(self.out, terminal::Clear(ClearType::All))?;
        let skip: usize = if self.selected_id > self.cursor_y as usize {
            self.selected_id - self.cursor_y as usize
        } else {
            0
        };
        for (i, line) in self
            .lines
            .iter()
            .skip(skip as usize)
            .take(self.height as usize)
            .enumerate()
        {
            queue!(
                self.out,
                cursor::MoveTo(START_X, START_Y + i as u16),
                Print(line)
            )?;
        }
        self.style_at_cursor()?;
        if self.help_popup_open {
            self.help_popup()?;
        }
        self.out.flush()
    }

    fn suspend(&mut self) -> io::Result<()> {
        #[cfg(not(windows))]
        {
            self.give_up_term()?;
            signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP).unwrap();
        }
        Ok(())
    }

    fn resume(&mut self) -> io::Result<()> {
        self.set_dims(terminal::size()?);
        self.claim_term()?;
        self.draw()?;
        Ok(())
    }

    fn bottom(&mut self) -> io::Result<()> {
        if self.selected_id == self.max_line_id() {
            return Ok(());
        }
        self.pi.bottom();
        self.selected_id = self.max_line_id();
        self.cursor_y = (self.height - 1).min(self.max_line_id() as u16);
        self.draw()
    }

    fn top(&mut self) -> io::Result<()> {
        if self.selected_id == 0 {
            return Ok(());
        }
        self.pi.top();
        self.selected_id = 0;
        self.cursor_y = 0;
        self.draw()
    }

    fn down(&mut self, try_dist: u16) -> io::Result<()> {
        self.destyle_at_cursor()?;
        let dist: usize = (try_dist as usize).min(self.max_line_id() - self.selected_id);
        for _ in 0..dist {
            self.selected_id += 1;
            self.pi.down(self.selected_id);
            if self.cursor_y < self.height - self.scroll_offset - 1
                || self.selected_id + self.scroll_offset as usize > self.max_line_id()
            {
                self.cursor_y += 1;
            } else {
                queue!(
                    self.out,
                    terminal::ScrollUp(1),
                    cursor::MoveTo(START_X, self.height),
                    Print(
                        self.lines
                            .get(self.selected_id + self.scroll_offset as usize)
                            .unwrap()
                    )
                )?;
            }
        }
        self.style_at_cursor()?;
        self.out.flush()
    }

    fn up(&mut self, try_dist: u16) -> io::Result<()> {
        self.destyle_at_cursor()?;
        let dist: usize = (try_dist as usize).min(self.selected_id);
        for _ in 0..dist {
            self.selected_id -= 1;
            self.pi.up(self.selected_id);
            if self.cursor_y > self.scroll_offset || self.selected_id < self.scroll_offset as usize
            {
                self.cursor_y -= 1;
            } else {
                queue!(
                    self.out,
                    terminal::ScrollDown(1),
                    cursor::MoveTo(START_X, START_Y),
                    Print(
                        self.lines
                            .get(self.selected_id - self.cursor_y as usize)
                            .unwrap()
                    )
                )?;
            }
        }
        self.style_at_cursor()?;
        self.out.flush()
    }

    pub fn down_path(&mut self) -> io::Result<()> {
        let dist = self.pi.dist_down(self.selected_id);
        if dist != 0 {
            self.down(dist)?;
        }
        Ok(())
    }

    fn up_path(&mut self) -> io::Result<()> {
        let dist = self.pi.dist_up(self.selected_id);
        if dist != 0 {
            self.up(dist)?;
        }
        Ok(())
    }

    fn style_at_cursor(&mut self) -> io::Result<()> {
        if self.colors {
            queue!(self.out, SetBackgroundColor(formats::MENU_SELECTED))?;
        }
        queue!(
            self.out,
            cursor::MoveTo(0, self.cursor_y),
            Print(formats::SELECTED_INDICATOR),
            cursor::MoveTo(START_X, self.cursor_y),
            Print(self.lines.get(self.selected_id).unwrap())
        )
    }

    fn destyle_at_cursor(&mut self) -> io::Result<()> {
        queue!(
            self.out,
            cursor::MoveTo(0, self.cursor_y),
            Print(formats::SELECTED_INDICATOR_CLEAR),
            cursor::MoveTo(START_X, self.cursor_y),
            Print(self.lines.get(self.selected_id).unwrap())
        )
    }

    fn help_popup(&mut self) -> io::Result<()> {
        let contents = MENU_HELP.to_string() + "\npress q to quit this popup";
        let lines: Vec<&str> = contents.lines().collect();
        let content_width = lines.iter().map(|line| line.len()).max().unwrap() as u16;
        let height = lines.len() as u16 + 2;
        let x = self.width.saturating_sub(content_width) / 2;
        let y = self.height.saturating_sub(height) / 2;

        queue!(
            self.out,
            cursor::MoveTo(x, y),
            Print(format!(
                "{}{}{}",
                config().c.tl,
                formats::repeat(formats::HORIZONTAL, content_width as usize),
                config().c.tr,
            ))
        )?;

        for (i, line) in lines.iter().enumerate() {
            queue!(
                self.out,
                cursor::MoveTo(x, y + i as u16 + 1),
                Print(format!(
                    "{}{:w$}{}",
                    formats::VERTICAL,
                    line,
                    formats::VERTICAL,
                    w = content_width as usize
                ),),
            )?;
        }

        queue!(
            self.out,
            cursor::MoveTo(x, y + height - 1),
            Print(format!(
                "{}{}{}",
                config().c.bl,
                formats::repeat(formats::HORIZONTAL, content_width as usize),
                config().c.br,
            ))
        )?;
        self.help_popup_open = true;
        self.out.flush()?;

        Ok(())
    }

    fn claim_term(&mut self) -> io::Result<()> {
        execute!(
            self.out,
            cursor::Hide,
            terminal::EnterAlternateScreen,
            terminal::DisableLineWrap,
        )?;
        terminal::enable_raw_mode()
    }

    fn give_up_term(&mut self) -> io::Result<()> {
        terminal::disable_raw_mode()?;
        self.out.flush()?;
        execute!(
            io::stderr(),
            style::ResetColor,
            cursor::SetCursorStyle::DefaultUserShape,
            terminal::LeaveAlternateScreen,
            terminal::EnableLineWrap,
            cursor::Show,
        )
    }

    fn max_line_id(&self) -> usize {
        self.lines.len() - 2
    }

    #[cfg(windows)]
    fn exit_and_open(&mut self, path: OsString, _line_num: Option<usize>) -> io::Result<()> {
        Command::new("cmd")
            .arg("/C")
            .arg("start")
            .arg(path)
            .spawn()?;
        self.give_up_term()
    }

    #[cfg(not(windows))]
    fn exit_and_open(&mut self, mut path: OsString, line_num: Option<usize>) -> io::Result<()> {
        let opener = match std::env::var("EDITOR") {
            Ok(val) if !val.is_empty() => val,
            _ => match std::env::consts::OS {
                "macos" => "open".to_string(),
                _ => "xdg-open".to_string(),
            },
        };

        let mut command: Command = Command::new(&opener);
        match opener.as_str() {
            "vi" | "vim" | "nvim" | "nano" | "emacs" | "jove" | "kak" | "micro" => {
                if let Some(l) = line_num {
                    command.arg(format!("+{l}"));
                }
                command.arg(path);
            }
            "hx" => {
                if let Some(l) = line_num {
                    path.push(format!(":{l}"));
                    command.arg(path);
                } else {
                    command.arg(path);
                }
            }
            "code" => {
                if let Some(l) = line_num {
                    command.arg("--goto");
                    path.push(format!(":{l}"));
                    command.arg(path);
                } else {
                    command.arg(path);
                }
            }
            "jed" | "xjed" => {
                command.arg(path);
                if let Some(l) = line_num {
                    command.arg("-g");
                    command.arg(format!("{l}"));
                }
            }
            _ => {
                command.arg(path);
            }
        }
        use std::os::unix::process::CommandExt;
        self.give_up_term()?;

        command.exec();
        Ok(())
    }
}

struct MatchInfo {
    path: OsString,
    line_num: Option<usize>,
}

impl MatchInfo {
    pub fn new(path: OsString, line_num: Option<usize>) -> Self {
        MatchInfo { path, line_num }
    }

    pub fn find(selected: usize, searched: &Matches) -> MatchInfo {
        let mut current: usize = 0;
        match searched {
            Matches::Dir(dirs) => {
                return Self::search_dir(dirs.get(0).unwrap(), selected, &mut current, dirs)
                    .unwrap();
            }
            Matches::File(file) => {
                return Self::search_file(file, selected, &mut current).unwrap();
            }
        }
    }

    fn search_dir(
        dir: &Directory,
        selected: usize,
        current: &mut usize,
        dirs: &Vec<Directory>,
    ) -> Option<Self> {
        let children = &dir.children;
        let files = &dir.files;
        if *current == selected {
            return Some(Self::new(dir.path.as_os_str().to_owned(), None));
        }
        *current += 1;
        for child in children {
            if let Some(sel) = Self::search_dir(dirs.get(*child).unwrap(), selected, current, dirs)
            {
                return Some(sel);
            }
        }
        for file in files {
            if let Some(sel) = Self::search_file(file, selected, current) {
                return Some(sel);
            }
        }
        return None;
    }

    fn search_file(file: &File, selected: usize, current: &mut usize) -> Option<Self> {
        if *current == selected {
            return Some(Self::new(file.path.clone().into_os_string(), None));
        }
        *current += 1;
        if !config().just_files {
            for line in file.lines.iter() {
                if *current == selected {
                    return Some(Self::new(file.path.clone().into_os_string(), line.line_num));
                }
                *current += 1;
            }
        }
        None
    }
}
