hackatime 1.0.0

Terminal CLI for viewing Hackatime stats with OAuth login
use std::{
    fs,
    io::{self, IsTerminal, Write},
    os::fd::AsRawFd,
    path::PathBuf,
};

use anyhow::{Context, Result};
use libc::{TCSANOW, VMIN, VTIME, cfmakeraw, tcgetattr, tcsetattr, termios};
use serde::{Deserialize, Serialize};

use crate::{models::FetchTheme, output, storage};

#[derive(Debug, Clone, Copy)]
pub struct UserSettings {
    pub fetch_theme: FetchTheme,
    pub clear_terminal: bool,
}

impl Default for UserSettings {
    fn default() -> Self {
        Self {
            fetch_theme: FetchTheme::Red,
            clear_terminal: true,
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
struct StoredSettings {
    fetch_theme: String,
    clear_terminal: bool,
}

const THEME_OPTIONS: [FetchTheme; 7] = [
    FetchTheme::Red,
    FetchTheme::Blue,
    FetchTheme::Green,
    FetchTheme::Yellow,
    FetchTheme::Pink,
    FetchTheme::Cyan,
    FetchTheme::Noir,
];

pub fn load_settings() -> Result<UserSettings> {
    let path = settings_file_path()?;
    if !path.exists() {
        return Ok(UserSettings::default());
    }

    let contents = fs::read_to_string(&path)
        .with_context(|| format!("failed to read settings file at {}", path.display()))?;
    let stored: StoredSettings = serde_json::from_str(&contents)
        .with_context(|| format!("failed to parse settings file at {}", path.display()))?;

    Ok(UserSettings {
        fetch_theme: FetchTheme::parse(&stored.fetch_theme).unwrap_or(FetchTheme::Red),
        clear_terminal: stored.clear_terminal,
    })
}

pub fn save_settings(settings: &UserSettings) -> Result<()> {
    let path = settings_file_path()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).with_context(|| {
            format!(
                "failed to create settings dir at {} (set {} to override the storage location)",
                parent.display(),
                storage::CONFIG_DIR_ENV
            )
        })?;
    }

    let stored = StoredSettings {
        fetch_theme: settings.fetch_theme.as_str().to_string(),
        clear_terminal: settings.clear_terminal,
    };

    let json =
        serde_json::to_string_pretty(&stored).context("failed to serialize user settings")?;
    fs::write(&path, json).with_context(|| {
        format!(
            "failed to write settings file at {} (set {} to override the storage location)",
            path.display(),
            storage::CONFIG_DIR_ENV
        )
    })
}

pub fn format_settings_summary(settings: &UserSettings) -> String {
    format!(
        "Theme Color: {}\nClear Terminal: {}\nSettings File: {}",
        settings.fetch_theme.as_str(),
        if settings.clear_terminal { "on" } else { "off" },
        settings_file_path()
            .map(|path| path.display().to_string())
            .unwrap_or_else(|_| "Unavailable".to_string())
    )
}

pub fn open_color_picker(settings: &mut UserSettings) -> Result<bool> {
    let stdin = io::stdin();
    let stdout = io::stdout();
    if !stdin.is_terminal() || !stdout.is_terminal() {
        anyhow::bail!("`hackatime settings color` requires an interactive terminal.");
    }

    let mut stdout = stdout.lock();
    let terminal = RawTerminalGuard::new(stdin.as_raw_fd(), &mut stdout)?;
    let mut selected_index = theme_index(settings.fetch_theme);

    loop {
        render_color_picker(&mut stdout, THEME_OPTIONS[selected_index])?;

        match read_picker_key(terminal.fd)? {
            PickerKey::Previous => {
                selected_index = if selected_index == 0 {
                    THEME_OPTIONS.len() - 1
                } else {
                    selected_index - 1
                };
            }
            PickerKey::Next => {
                selected_index = (selected_index + 1) % THEME_OPTIONS.len();
            }
            PickerKey::Confirm => {
                settings.fetch_theme = THEME_OPTIONS[selected_index];
                save_settings(settings)?;
                break Ok(true);
            }
            PickerKey::Cancel => break Ok(false),
            PickerKey::Ignore => {}
        }
    }
}

fn settings_file_path() -> Result<PathBuf> {
    Ok(storage::app_config_dir()?.join("settings.json"))
}

fn theme_index(theme: FetchTheme) -> usize {
    THEME_OPTIONS
        .iter()
        .position(|option| *option == theme)
        .unwrap_or(0)
}

fn render_color_picker(stdout: &mut impl Write, selected_theme: FetchTheme) -> Result<()> {
    let mut screen = String::new();
    screen.push_str("\x1b[2J\x1b[H");
    push_line(&mut screen, "Choose Theme Color");
    push_line(&mut screen, "==================");
    push_line(&mut screen, "");

    for line in picker_logo(selected_theme) {
        push_line(&mut screen, &line);
    }

    push_line(&mut screen, "");
    push_line(
        &mut screen,
        &format!(
            "{} {}",
            paint(
                "Selected Color:",
                ColorStyle {
                    fg: fetch_palette(selected_theme).label,
                    bold: true,
                }
            ),
            paint(
                selected_theme.as_str(),
                ColorStyle {
                    fg: fetch_palette(selected_theme).title,
                    bold: true,
                }
            )
        ),
    );
    push_line(&mut screen, "");
    push_line(&mut screen, "Use Left/Right arrows to change the color.");
    push_line(&mut screen, "Press Enter to save or Esc to cancel.");
    push_line(&mut screen, "");
    push_line(&mut screen, &picker_options_row(selected_theme));

    stdout
        .write_all(screen.as_bytes())
        .context("failed to draw color picker")?;
    stdout.flush().context("failed to draw color picker")
}

fn push_line(buffer: &mut String, line: &str) {
    buffer.push_str(line);
    buffer.push_str("\r\n");
}

fn picker_logo(theme: FetchTheme) -> Vec<String> {
    output::fetch_logo_preview(14, theme)
}

fn picker_options_row(selected_theme: FetchTheme) -> String {
    THEME_OPTIONS
        .iter()
        .map(|theme| {
            let palette = fetch_palette(*theme);
            let label = format!(" {} ", theme.as_str());
            if *theme == selected_theme {
                format!(
                    "{}{}{}",
                    ansi_bg(palette.title),
                    paint(&label, ColorStyle { fg: 16, bold: true }),
                    ansi_reset()
                )
            } else {
                paint(
                    &label,
                    ColorStyle {
                        fg: palette.label,
                        bold: false,
                    },
                )
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

#[derive(Clone, Copy)]
struct FetchPalette {
    title: u8,
    label: u8,
}

fn fetch_palette(theme: FetchTheme) -> FetchPalette {
    match theme {
        FetchTheme::Red => FetchPalette {
            title: 203,
            label: 210,
        },
        FetchTheme::Blue => FetchPalette {
            title: 39,
            label: 45,
        },
        FetchTheme::Green => FetchPalette {
            title: 40,
            label: 48,
        },
        FetchTheme::Yellow => FetchPalette {
            title: 220,
            label: 228,
        },
        FetchTheme::Pink => FetchPalette {
            title: 176,
            label: 213,
        },
        FetchTheme::Cyan => FetchPalette {
            title: 44,
            label: 51,
        },
        FetchTheme::Noir => FetchPalette {
            title: 252,
            label: 248,
        },
    }
}

#[derive(Clone, Copy)]
struct ColorStyle {
    fg: u8,
    bold: bool,
}

fn paint(text: &str, style: ColorStyle) -> String {
    format!("{}{}{}", ansi(style.fg, style.bold), text, ansi_reset())
}

fn ansi(code: u8, bold: bool) -> String {
    if bold {
        format!("\x1b[1;38;5;{code}m")
    } else {
        format!("\x1b[38;5;{code}m")
    }
}

fn ansi_bg(code: u8) -> String {
    format!("\x1b[48;5;{code}m")
}

fn ansi_reset() -> &'static str {
    "\x1b[0m"
}

enum PickerKey {
    Previous,
    Next,
    Confirm,
    Cancel,
    Ignore,
}

fn read_picker_key(fd: i32) -> Result<PickerKey> {
    loop {
        let Some(byte) = read_byte(fd)? else {
            continue;
        };

        return Ok(match byte {
            b'\r' | b'\n' => PickerKey::Confirm,
            b'q' | b'Q' | 3 => PickerKey::Cancel,
            b'\x1b' => match read_byte(fd)? {
                Some(b'[') => match read_byte(fd)? {
                    Some(b'D') | Some(b'A') => PickerKey::Previous,
                    Some(b'C') | Some(b'B') => PickerKey::Next,
                    _ => PickerKey::Cancel,
                },
                _ => PickerKey::Cancel,
            },
            _ => PickerKey::Ignore,
        });
    }
}

fn read_byte(fd: i32) -> Result<Option<u8>> {
    let mut byte = [0_u8; 1];
    let read_result = unsafe { libc::read(fd, byte.as_mut_ptr().cast(), 1) };
    if read_result < 0 {
        return Err(io::Error::last_os_error()).context("failed to read keyboard input");
    }
    if read_result == 0 {
        return Ok(None);
    }
    Ok(Some(byte[0]))
}

struct RawTerminalGuard {
    fd: i32,
    original: termios,
}

impl RawTerminalGuard {
    fn new(fd: i32, stdout: &mut impl Write) -> Result<Self> {
        let mut original = unsafe { std::mem::zeroed::<termios>() };
        let get_result = unsafe { tcgetattr(fd, &mut original) };
        if get_result != 0 {
            return Err(io::Error::last_os_error()).context("failed to read terminal state");
        }

        let mut raw = original;
        unsafe {
            cfmakeraw(&mut raw);
        }
        raw.c_cc[VMIN] = 0;
        raw.c_cc[VTIME] = 1;

        let set_result = unsafe { tcsetattr(fd, TCSANOW, &raw) };
        if set_result != 0 {
            return Err(io::Error::last_os_error()).context("failed to enable raw terminal mode");
        }

        write!(stdout, "\x1b[?1049h\x1b[?25l")?;
        stdout.flush()?;

        Ok(Self { fd, original })
    }
}

impl Drop for RawTerminalGuard {
    fn drop(&mut self) {
        unsafe {
            tcsetattr(self.fd, TCSANOW, &self.original);
        }
        print!("\x1b[?25h\x1b[?1049l");
        let _ = io::stdout().flush();
    }
}