tix 0.1.3

tix - cli alarm clock and timer with foreground and background modes
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use std::fmt::Write as FmtWrite;
use std::io::{self, IsTerminal, Write};
use std::time::Duration;

use crate::schedule::write_alarm_time;
use crate::types::{ForegroundConfig, TimeNotation, TimerStyle};

pub struct ForegroundRenderer {
    enabled: bool,
    settings: ForegroundConfig,
    timezone: Tz,
    time_notation: TimeNotation,
    target_local: DateTime<Tz>,
    target_utc: DateTime<Utc>,
    spec_text: String,
    title_line: String,
    input_line: String,
    current_line: String,
    target_line: String,
    remaining_line: String,
    rendered_lines: usize,
}

impl ForegroundRenderer {
    pub fn new(
        settings: ForegroundConfig,
        timezone: Tz,
        time_notation: TimeNotation,
        target_local: DateTime<Tz>,
        spec_text: &str,
        label: Option<&str>,
    ) -> Self {
        let title_line = match label {
            Some(label) => format!("TIX | {label}"),
            None => String::from("TIX | Foreground Alarm"),
        };
        Self {
            enabled: io::stdout().is_terminal(),
            settings,
            timezone,
            time_notation,
            target_local,
            target_utc: target_local.with_timezone(&Utc),
            spec_text: spec_text.to_string(),
            title_line,
            input_line: String::with_capacity(spec_text.len() + 7),
            current_line: String::with_capacity(48),
            target_line: String::with_capacity(48),
            remaining_line: String::with_capacity(32),
            rendered_lines: 0,
        }
    }

    pub fn enabled(&self) -> bool {
        self.enabled
    }

    pub fn refresh_interval(&self) -> Duration {
        Duration::from_millis(self.settings.effective_refresh_interval_ms())
    }

    pub fn render(&mut self, now_utc: DateTime<Utc>) -> io::Result<()> {
        if !self.enabled {
            return Ok(());
        }

        let now_local = now_utc.with_timezone(&self.timezone);
        let remaining = (self.target_utc - now_utc)
            .to_std()
            .unwrap_or(Duration::ZERO);
        let mut lines = [&self.title_line[..], "", "", "", ""];
        let mut line_count = 1_usize;

        if self.settings.show_input {
            self.input_line.clear();
            self.input_line.push_str("Input: ");
            self.input_line.push_str(&self.spec_text);
            lines[line_count] = &self.input_line;
            line_count += 1;
        }
        if self.settings.show_current_datetime {
            self.current_line.clear();
            self.current_line.push_str("Now: ");
            write_alarm_time(now_local, self.time_notation, &mut self.current_line);
            lines[line_count] = &self.current_line;
            line_count += 1;
        }
        if self.settings.show_target_datetime {
            self.target_line.clear();
            self.target_line.push_str("Target: ");
            write_alarm_time(self.target_local, self.time_notation, &mut self.target_line);
            lines[line_count] = &self.target_line;
            line_count += 1;
        }
        if self.settings.show_remaining {
            self.remaining_line.clear();
            self.remaining_line.push_str("Remaining: ");
            format_remaining_into(
                remaining,
                self.settings.timer_style,
                &mut self.remaining_line,
            );
            lines[line_count] = &self.remaining_line;
            line_count += 1;
        }

        let rendered_lines = self.redraw(&lines[..line_count])?;
        self.rendered_lines = rendered_lines;
        Ok(())
    }

    pub fn clear(&mut self) -> io::Result<()> {
        if !self.enabled || self.rendered_lines == 0 {
            return Ok(());
        }

        let mut stdout = io::stdout().lock();
        write!(stdout, "\r")?;
        if self.rendered_lines > 1 {
            write!(stdout, "\x1b[{}A", self.rendered_lines - 1)?;
        }
        write!(stdout, "\x1b[J")?;
        stdout.flush()?;
        self.rendered_lines = 0;
        Ok(())
    }

    fn redraw(&self, lines: &[&str]) -> io::Result<usize> {
        const MIN_WIDTH: usize = 36;

        let mut stdout = io::stdout().lock();
        if self.rendered_lines > 0 {
            write!(stdout, "\r")?;
            if self.rendered_lines > 1 {
                write!(stdout, "\x1b[{}A", self.rendered_lines - 1)?;
            }
            write!(stdout, "\x1b[J")?;
        }

        let width = lines
            .iter()
            .map(|line| line.len())
            .max()
            .unwrap_or(0)
            .max(MIN_WIDTH);
        write!(stdout, "+")?;
        for _ in 0..(width + 2) {
            write!(stdout, "-")?;
        }
        writeln!(stdout, "+")?;

        for line in lines {
            writeln!(stdout, "| {line:<width$} |")?;
        }

        write!(stdout, "+")?;
        for _ in 0..(width + 2) {
            write!(stdout, "-")?;
        }
        write!(stdout, "+")?;
        stdout.flush()?;
        Ok(lines.len() + 2)
    }
}

#[cfg(test)]
fn format_remaining(remaining: Duration, timer_style: TimerStyle) -> String {
    let mut rendered = String::with_capacity(24);
    format_remaining_into(remaining, timer_style, &mut rendered);
    rendered
}

fn format_remaining_into(remaining: Duration, timer_style: TimerStyle, out: &mut String) {
    match timer_style {
        TimerStyle::Digital => format_remaining_digital_into(remaining, out),
        TimerStyle::Human => format_remaining_human_into(remaining, out),
    }
}

fn format_remaining_digital_into(remaining: Duration, out: &mut String) {
    let total_seconds = remaining.as_secs();
    let days = total_seconds / 86_400;
    let hours = (total_seconds % 86_400) / 3_600;
    let minutes = (total_seconds % 3_600) / 60;
    let seconds = total_seconds % 60;

    if days > 0 {
        let _ = write!(out, "{days}d {hours:02}:{minutes:02}:{seconds:02}");
    } else {
        let _ = write!(out, "{hours:02}:{minutes:02}:{seconds:02}");
    }
}

fn format_remaining_human_into(remaining: Duration, out: &mut String) {
    let total_seconds = remaining.as_secs();
    let days = total_seconds / 86_400;
    let hours = (total_seconds % 86_400) / 3_600;
    let minutes = (total_seconds % 3_600) / 60;
    let seconds = total_seconds % 60;

    if days > 0 {
        let _ = write!(out, "{days}d");
    }
    if hours > 0 || !out.is_empty() {
        if !out.is_empty() {
            out.push(' ');
        }
        let _ = write!(out, "{hours}h");
    }
    if minutes > 0 || !out.is_empty() {
        if !out.is_empty() {
            out.push(' ');
        }
        let _ = write!(out, "{minutes}m");
    }
    if !out.is_empty() {
        out.push(' ');
    }
    let _ = write!(out, "{seconds}s");
}

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

    #[test]
    fn human_remaining_formatting_matches_expected_text() {
        assert_eq!(
            format_remaining(Duration::from_secs(90_061), TimerStyle::Human),
            "1d 1h 1m 1s"
        );
    }

    #[test]
    fn digital_remaining_formatting_matches_expected_text() {
        assert_eq!(
            format_remaining(Duration::from_secs(90_061), TimerStyle::Digital),
            "1d 01:01:01"
        );
    }
}