use nils_common::cli_contract::exit;
use nils_common::diag_output;
use nils_common::env as shared_env;
use nils_common::process;
use nils_common::shell::{AnsiStripMode, quote_posix_single, strip_ansi};
use nils_common::usage_time::reset_epoch_seconds_from_str;
use serde::Serialize;
use serde_json::{Map, Value, json};
use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use super::{auth, cache, client, render};

const USAGE_SCHEMA_VERSION: &str = "claude-cli.usage.v1";
const USAGE_COMMAND: &str = "usage";
const DEFAULT_CLAUDE_BIN: &str = "claude";
const DEFAULT_CLAUDE_TIMEOUT_SECONDS: u64 = 15;
const DEFAULT_PTY_STARTUP_DELAY_MS: u64 = 4_000;
const DEFAULT_PTY_USAGE_DELAY_MS: u64 = 3_000;
const CLI_USAGE_STDIN: &[u8] = b"/usage\n/exit\n";
const CLI_USAGE_PTY_USAGE_STDIN: &[u8] = b"/usage\r";
const CLI_USAGE_PTY_EXIT_STDIN: &[u8] = b"/exit\r";

#[derive(Clone, Debug)]
pub struct UsageOptions {
    pub source: UsageSource,
    pub output_json: bool,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum UsageSource {
    Auto,
    Oauth,
    Cli,
    Cache,
}

#[derive(Debug, Clone, Serialize)]
struct UsageResult {
    provider: String,
    source: String,
    stale: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    cache_file: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    updated_at: Option<i64>,
    windows: Vec<UsageWindow>,
    #[serde(skip_serializing_if = "Option::is_none")]
    plan: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    note: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
struct UsageWindow {
    key: String,
    label: String,
    window_minutes: i64,
    used_percent: f64,
    remaining_percent: f64,
    #[serde(skip_serializing_if = "Option::is_none")]
    resets_at: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    resets_at_epoch: Option<i64>,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum WindowKind {
    FiveHour,
    SevenDay,
}

#[derive(Default)]
struct ParsedWindow {
    used_percent: Option<f64>,
    resets_at: Option<String>,
}

pub fn run(options: &UsageOptions) -> i32 {
    let result = resolve_usage(options.source);

    if options.output_json {
        if diag_output::emit_success_result(USAGE_SCHEMA_VERSION, USAGE_COMMAND, &result).is_err() {
            return exit::RUNTIME;
        }
    } else {
        println!("{}", render_text_result(&result));
    }

    exit::SUCCESS
}

fn resolve_usage(source: UsageSource) -> UsageResult {
    let cache_file = cache::cache_file();
    match source {
        UsageSource::Auto => {
            if let Some(result) = try_oauth(cache_file.as_ref()) {
                return result;
            }
            if let Some(result) = try_claude_cli(cache_file.as_ref()) {
                return result;
            }
            read_cache(cache_file.as_ref())
                .unwrap_or_else(|| empty_result(cache_file, "usage unavailable"))
        }
        UsageSource::Oauth => try_oauth(cache_file.as_ref())
            .unwrap_or_else(|| empty_result(cache_file, "oauth usage unavailable")),
        UsageSource::Cli => try_claude_cli(cache_file.as_ref())
            .unwrap_or_else(|| empty_result(cache_file, "claude cli usage unavailable")),
        UsageSource::Cache => read_cache(cache_file.as_ref())
            .unwrap_or_else(|| empty_result(cache_file, "cache missing")),
    }
}

fn try_oauth(cache_file: Option<&PathBuf>) -> Option<UsageResult> {
    let token = auth::resolve_access_token()?;
    let body = client::fetch_usage(&token.value).ok()?;
    let value: Value = serde_json::from_str(&body).ok()?;
    let usage = render::parse_usage_value(&value)?;
    if let Some(cache_file) = cache_file {
        let _ = cache::write_cache_file(cache_file, &body);
    }
    Some(result_from_usage(
        "oauth",
        false,
        cache_file,
        Some(now_epoch_seconds()),
        &usage,
        None,
    ))
}

fn try_claude_cli(cache_file: Option<&PathBuf>) -> Option<UsageResult> {
    let body = probe_claude_cli_usage().ok()?;
    let usage = parse_cli_usage_output(&body)?;
    let cache_body = usage_cache_body(&usage).ok()?;
    if let Some(cache_file) = cache_file {
        let _ = cache::write_cache_file(cache_file, &cache_body);
    }
    Some(result_from_usage(
        "cli",
        false,
        cache_file,
        Some(now_epoch_seconds()),
        &usage,
        None,
    ))
}

fn read_cache(cache_file: Option<&PathBuf>) -> Option<UsageResult> {
    let cache_file = cache_file?;
    let raw = cache::read_cache_file(cache_file)?;
    let value: Value = serde_json::from_str(&raw).ok()?;
    let usage = render::parse_usage_value(&value)?;
    Some(result_from_usage(
        "cache",
        true,
        Some(cache_file),
        modified_epoch_seconds(cache_file),
        &usage,
        Some("serving last cached usage".to_string()),
    ))
}

fn empty_result(cache_file: Option<PathBuf>, note: &str) -> UsageResult {
    UsageResult {
        provider: "claude".to_string(),
        source: "none".to_string(),
        stale: true,
        cache_file: cache_file.as_ref().map(|path| display_path(path)),
        updated_at: None,
        windows: Vec::new(),
        plan: None,
        note: Some(note.to_string()),
    }
}

fn result_from_usage(
    source: &str,
    stale: bool,
    cache_file: Option<&PathBuf>,
    updated_at: Option<i64>,
    usage: &render::Usage,
    note: Option<String>,
) -> UsageResult {
    let mut windows = Vec::new();
    if let Some(window) = &usage.five_hour {
        windows.push(usage_window("5h", "5h", 300, window, updated_at));
    }
    if let Some(window) = &usage.seven_day {
        windows.push(usage_window("weekly", "Weekly", 10_080, window, updated_at));
    }

    UsageResult {
        provider: "claude".to_string(),
        source: source.to_string(),
        stale,
        cache_file: cache_file.map(|path| display_path(path)),
        updated_at,
        windows,
        plan: None,
        note,
    }
}

fn usage_window(
    key: &str,
    label: &str,
    window_minutes: i64,
    window: &render::Window,
    reference_epoch: Option<i64>,
) -> UsageWindow {
    let resets_at = window.resets_at.clone();
    let resets_at_epoch = resets_at
        .as_deref()
        .and_then(|raw| reset_epoch_seconds(raw, reference_epoch));
    UsageWindow {
        key: key.to_string(),
        label: label.to_string(),
        window_minutes,
        used_percent: round_percent(window.used_percent),
        remaining_percent: round_percent(f64::from(window.remaining_percent as i32)),
        resets_at,
        resets_at_epoch,
    }
}

fn render_text_result(result: &UsageResult) -> String {
    if result.windows.is_empty() {
        return result
            .note
            .clone()
            .unwrap_or_else(|| "usage unavailable".to_string());
    }

    let mut parts = vec![format!("source={}", result.source)];
    for window in &result.windows {
        parts.push(format!(
            "{}:{}%",
            window.label,
            round_percent(window.remaining_percent)
        ));
    }
    if result.stale {
        parts.push("(stale)".to_string());
    }
    parts.join(" ")
}

fn probe_claude_cli_usage() -> anyhow::Result<String> {
    let claude_bin = shared_env::env_non_empty("CLAUDE_PROMPT_SEGMENT_CLAUDE_BIN")
        .unwrap_or_else(|| DEFAULT_CLAUDE_BIN.to_string());
    let program = process::find_in_path(&claude_bin).unwrap_or_else(|| PathBuf::from(&claude_bin));
    let timeout_seconds = shared_env::env_non_empty("CLAUDE_PROMPT_SEGMENT_CLAUDE_TIMEOUT_SECONDS")
        .and_then(|raw| raw.parse::<u64>().ok())
        .filter(|value| *value > 0)
        .unwrap_or(DEFAULT_CLAUDE_TIMEOUT_SECONDS);

    let mut last_error: Option<anyhow::Error> = None;
    for mode in select_probe_modes(&program) {
        match probe_claude_cli_usage_with_mode(&program, timeout_seconds, &mode) {
            Ok(text)
                if parse_cli_usage_output(&text).is_some() || matches!(mode, ProbeMode::Pipe) =>
            {
                return Ok(text);
            }
            Ok(_) => {
                last_error = Some(anyhow::anyhow!(
                    "claude usage probe output was not parseable"
                ));
            }
            Err(error) => {
                last_error = Some(error);
            }
        }
    }

    Err(last_error.unwrap_or_else(|| anyhow::anyhow!("claude usage probe unavailable")))
}

fn probe_claude_cli_usage_with_mode(
    program: &Path,
    timeout_seconds: u64,
    mode: &ProbeMode,
) -> anyhow::Result<String> {
    let mut command = mode.command(program);
    let mut child = command
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()?;
    if let Some(mut stdin) = child.stdin.take() {
        mode.write_usage_input(&mut stdin)?;
    }

    let deadline = Instant::now() + Duration::from_secs(timeout_seconds);
    loop {
        if child.try_wait()?.is_some() {
            let output = child.wait_with_output()?;
            let text = output_text(&output.stdout, &output.stderr);
            if output.status.success() || !text.trim().is_empty() {
                return Ok(text);
            }
            anyhow::bail!("claude exited nonzero");
        }

        if Instant::now() >= deadline {
            let _ = child.kill();
            let output = child.wait_with_output()?;
            let text = output_text(&output.stdout, &output.stderr);
            if !text.trim().is_empty() {
                return Ok(text);
            }
            anyhow::bail!("claude usage probe timed out");
        }

        thread::sleep(Duration::from_millis(25));
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
enum ProbeMode {
    Pty(ScriptLauncher),
    Pipe,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct ScriptLauncher {
    program: PathBuf,
    flavor: ScriptFlavor,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum ScriptFlavor {
    UtilLinux,
    Bsd,
}

impl ProbeMode {
    fn command(&self, program: &Path) -> Command {
        match self {
            Self::Pty(launcher) => script_command(program, launcher),
            Self::Pipe => Command::new(program),
        }
    }

    fn write_usage_input(&self, stdin: &mut dyn Write) -> anyhow::Result<()> {
        match self {
            Self::Pty(_) => {
                thread::sleep(Duration::from_millis(env_u64(
                    "CLAUDE_PROMPT_SEGMENT_CLAUDE_PTY_STARTUP_DELAY_MS",
                    DEFAULT_PTY_STARTUP_DELAY_MS,
                )));
                stdin.write_all(CLI_USAGE_PTY_USAGE_STDIN)?;
                stdin.flush()?;
                thread::sleep(Duration::from_millis(env_u64(
                    "CLAUDE_PROMPT_SEGMENT_CLAUDE_PTY_USAGE_DELAY_MS",
                    DEFAULT_PTY_USAGE_DELAY_MS,
                )));
                stdin.write_all(CLI_USAGE_PTY_EXIT_STDIN)?;
                stdin.flush()?;
            }
            Self::Pipe => {
                stdin.write_all(CLI_USAGE_STDIN)?;
            }
        }
        Ok(())
    }
}

fn select_probe_modes(program: &Path) -> Vec<ProbeMode> {
    let script_program = if cfg!(unix) {
        process::find_in_path("script")
    } else {
        None
    };
    select_probe_modes_for(
        shared_env::env_truthy("CLAUDE_PROMPT_SEGMENT_CLAUDE_PTY_DISABLED"),
        program.is_file(),
        script_program,
    )
}

fn select_probe_modes_for(
    pty_disabled: bool,
    program_is_file: bool,
    script_program: Option<PathBuf>,
) -> Vec<ProbeMode> {
    let mut modes = Vec::new();
    if !pty_disabled
        && program_is_file
        && let Some(program) = script_program
    {
        modes.push(ProbeMode::Pty(ScriptLauncher {
            program,
            flavor: platform_script_flavor(),
        }));
    }
    modes.push(ProbeMode::Pipe);
    modes
}

fn script_command(program: &Path, launcher: &ScriptLauncher) -> Command {
    let mut command = Command::new(&launcher.program);
    command.args(script_command_args(program, launcher.flavor));
    command
}

fn script_command_args(program: &Path, flavor: ScriptFlavor) -> Vec<OsString> {
    match flavor {
        ScriptFlavor::UtilLinux => vec![
            OsString::from("-q"),
            OsString::from("/dev/null"),
            OsString::from("-c"),
            OsString::from(quote_posix_single(&program.to_string_lossy())),
        ],
        ScriptFlavor::Bsd => vec![
            OsString::from("-q"),
            OsString::from("/dev/null"),
            program.as_os_str().to_os_string(),
        ],
    }
}

fn platform_script_flavor() -> ScriptFlavor {
    if cfg!(any(
        target_os = "macos",
        target_os = "freebsd",
        target_os = "netbsd",
        target_os = "openbsd",
        target_os = "dragonfly"
    )) {
        ScriptFlavor::Bsd
    } else {
        ScriptFlavor::UtilLinux
    }
}

fn output_text(stdout: &[u8], stderr: &[u8]) -> String {
    let mut text = String::from_utf8_lossy(stdout).to_string();
    if !stderr.is_empty() {
        text.push('\n');
        text.push_str(&String::from_utf8_lossy(stderr));
    }
    text
}

fn parse_cli_usage_output(raw: &str) -> Option<render::Usage> {
    let stripped = strip_ansi(raw, AnsiStripMode::CsiAnyTerminator);
    let normalized = stripped.replace('\r', "\n");
    let mut current = None;
    let mut five_hour = ParsedWindow::default();
    let mut seven_day = ParsedWindow::default();

    for line in normalized.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let lower = trimmed.to_ascii_lowercase();
        if lower.contains("fable") {
            current = None;
            continue;
        }
        if let Some(kind) = classify_window_line(&lower) {
            current = Some(kind);
        }

        let kind = classify_window_line(&lower).or(current);
        if let Some(kind) = kind {
            if let Some(used_percent) = parse_used_percent(trimmed) {
                target_window(kind, &mut five_hour, &mut seven_day).used_percent =
                    Some(used_percent);
            }
            if lower.contains("reset")
                && let Some(resets_at) = parse_reset_value(trimmed)
            {
                target_window(kind, &mut five_hour, &mut seven_day).resets_at = Some(resets_at);
            }
        }
    }

    let five_hour = build_window(five_hour);
    let seven_day = build_window(seven_day);
    if five_hour.is_none() && seven_day.is_none() {
        return None;
    }
    Some(render::Usage {
        five_hour,
        seven_day,
    })
}

fn classify_window_line(lower: &str) -> Option<WindowKind> {
    if lower.contains("week") || lower.contains("7-day") || lower.contains("7 day") {
        return Some(WindowKind::SevenDay);
    }
    if lower.contains("5-hour")
        || lower.contains("5 hour")
        || lower.contains("5h")
        || lower.contains("session")
    {
        return Some(WindowKind::FiveHour);
    }
    None
}

fn target_window<'a>(
    kind: WindowKind,
    five_hour: &'a mut ParsedWindow,
    seven_day: &'a mut ParsedWindow,
) -> &'a mut ParsedWindow {
    match kind {
        WindowKind::FiveHour => five_hour,
        WindowKind::SevenDay => seven_day,
    }
}

fn build_window(parsed: ParsedWindow) -> Option<render::Window> {
    let used_percent = parsed.used_percent?;
    Some(render::Window {
        used_percent,
        remaining_percent: remaining_percent(used_percent),
        resets_at: parsed.resets_at,
    })
}

fn parse_used_percent(line: &str) -> Option<f64> {
    let lower = line.to_ascii_lowercase();
    let percentages = percent_values(&lower);
    if percentages.is_empty() {
        return None;
    }

    for word in ["used", "usage", "utilized"] {
        if let Some(value) = percent_before_word(&lower, &percentages, word) {
            return Some(value.clamp(0.0, 100.0));
        }
    }
    for word in ["remaining", "left", "available"] {
        if let Some(value) = percent_before_word(&lower, &percentages, word) {
            return Some((100.0 - value).clamp(0.0, 100.0));
        }
    }

    let first = percentages.first()?.1;
    if lower.contains("remaining") || lower.contains("left") {
        Some((100.0 - first).clamp(0.0, 100.0))
    } else {
        Some(first.clamp(0.0, 100.0))
    }
}

fn percent_values(line: &str) -> Vec<(usize, f64)> {
    let mut values = Vec::new();
    for (percent_index, ch) in line.char_indices() {
        if ch != '%' {
            continue;
        }
        let prefix = &line[..percent_index];
        let start = prefix
            .char_indices()
            .rev()
            .find(|(_, ch)| !ch.is_ascii_digit() && *ch != '.')
            .map(|(idx, ch)| idx + ch.len_utf8())
            .unwrap_or(0);
        if start >= percent_index {
            continue;
        }
        if let Ok(value) = line[start..percent_index].trim().parse::<f64>() {
            values.push((percent_index + 1, value));
        }
    }
    values
}

fn percent_before_word(line: &str, percentages: &[(usize, f64)], word: &str) -> Option<f64> {
    let word_index = line.find(word)?;
    percentages
        .iter()
        .take_while(|(end_index, _)| *end_index <= word_index)
        .last()
        .map(|(_, value)| *value)
}

fn parse_reset_value(line: &str) -> Option<String> {
    let lower = line.to_ascii_lowercase();
    for marker in ["resets at", "reset at", "resets:", "reset:"] {
        if let Some(index) = lower.find(marker) {
            let raw = line[index + marker.len()..]
                .trim()
                .trim_start_matches(':')
                .trim();
            if !raw.is_empty() {
                return Some(raw.to_string());
            }
        }
    }
    for marker in ["resets", "reset"] {
        if lower.starts_with(marker) {
            let raw = line[marker.len()..].trim();
            if !raw.is_empty() {
                return Some(raw.to_string());
            }
        }
    }
    line.split_once(':')
        .map(|(_, raw)| raw.trim())
        .filter(|raw| !raw.is_empty())
        .map(ToOwned::to_owned)
}

fn env_u64(key: &str, default: u64) -> u64 {
    shared_env::env_non_empty(key)
        .and_then(|raw| raw.parse::<u64>().ok())
        .unwrap_or(default)
}

fn usage_cache_body(usage: &render::Usage) -> serde_json::Result<String> {
    let mut usage_map = Map::new();
    if let Some(window) = &usage.five_hour {
        usage_map.insert("five_hour".to_string(), cache_window(window));
    }
    if let Some(window) = &usage.seven_day {
        usage_map.insert("seven_day".to_string(), cache_window(window));
    }
    serde_json::to_string(&json!({ "usage": Value::Object(usage_map) }))
}

fn cache_window(window: &render::Window) -> Value {
    let mut map = Map::new();
    map.insert(
        "utilization".to_string(),
        json!(round_percent(window.used_percent)),
    );
    if let Some(resets_at) = &window.resets_at {
        map.insert("resets_at".to_string(), json!(resets_at));
    }
    Value::Object(map)
}

fn remaining_percent(used_percent: f64) -> i64 {
    (100.0 - used_percent.round()).clamp(0.0, 100.0) as i64
}

fn round_percent(value: f64) -> f64 {
    (value * 10.0).round() / 10.0
}

fn reset_epoch_seconds(raw: &str, reference_epoch: Option<i64>) -> Option<i64> {
    reset_epoch_seconds_from_str(raw, reference_epoch)
}

fn now_epoch_seconds() -> i64 {
    epoch_seconds(SystemTime::now())
}

fn modified_epoch_seconds(path: &Path) -> Option<i64> {
    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
    Some(epoch_seconds(modified))
}

fn epoch_seconds(time: SystemTime) -> i64 {
    time.duration_since(UNIX_EPOCH)
        .ok()
        .and_then(|duration| i64::try_from(duration.as_secs()).ok())
        .unwrap_or(0)
}

fn display_path(path: &Path) -> String {
    path.to_string_lossy().to_string()
}

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

    #[test]
    fn cli_usage_parser_reads_used_and_remaining_lines() {
        let usage = parse_cli_usage_output(
            "\x1b[32mCurrent session\x1b[0m\n\
             5-hour limit: 25% used, 75% remaining\n\
             Resets at 2026-01-01T00:00:00+00:00\n\
             Current week\n\
             Weekly limit: 50% left\n",
        )
        .expect("usage");

        let five = usage.five_hour.expect("five hour");
        assert_eq!(five.used_percent, 25.0);
        assert_eq!(five.remaining_percent, 75);
        assert_eq!(five.resets_at.as_deref(), Some("2026-01-01T00:00:00+00:00"));

        let weekly = usage.seven_day.expect("weekly");
        assert_eq!(weekly.used_percent, 50.0);
        assert_eq!(weekly.remaining_percent, 50);
    }

    #[test]
    fn usage_cache_body_uses_prompt_segment_cache_shape() {
        let body = usage_cache_body(&render::Usage {
            five_hour: Some(render::Window {
                used_percent: 25.0,
                remaining_percent: 75,
                resets_at: None,
            }),
            seven_day: None,
        })
        .expect("cache body");

        let value: Value = serde_json::from_str(&body).expect("json");
        assert_eq!(value["usage"]["five_hour"]["utilization"], 25.0);
    }

    #[test]
    fn script_command_args_use_util_linux_c_shape() {
        let args = script_command_args(Path::new("/opt/bin/claude"), ScriptFlavor::UtilLinux);

        assert_eq!(
            args,
            vec!["-q", "/dev/null", "-c", "'/opt/bin/claude'"]
                .into_iter()
                .map(std::ffi::OsString::from)
                .collect::<Vec<_>>()
        );
    }

    #[test]
    fn script_command_args_use_bsd_file_command_shape() {
        let args = script_command_args(Path::new("/opt/bin/claude"), ScriptFlavor::Bsd);

        assert_eq!(
            args,
            vec!["-q", "/dev/null", "/opt/bin/claude"]
                .into_iter()
                .map(std::ffi::OsString::from)
                .collect::<Vec<_>>()
        );
    }

    #[test]
    fn pty_command_uses_resolved_script_path_and_flavor() {
        let mode = ProbeMode::Pty(ScriptLauncher {
            program: PathBuf::from("/custom/bin/script"),
            flavor: ScriptFlavor::Bsd,
        });
        let command = mode.command(Path::new("/opt/bin/claude"));

        assert_eq!(command.get_program(), Path::new("/custom/bin/script"));
        assert_eq!(
            command.get_args().collect::<Vec<_>>(),
            vec![
                std::ffi::OsStr::new("-q"),
                std::ffi::OsStr::new("/dev/null"),
                std::ffi::OsStr::new("/opt/bin/claude"),
            ]
        );
    }

    #[test]
    fn selected_probe_modes_keep_pipe_fallback_after_pty() {
        let modes = select_probe_modes_for(false, true, Some(PathBuf::from("/usr/bin/script")));

        assert_eq!(
            modes,
            vec![
                ProbeMode::Pty(ScriptLauncher {
                    program: PathBuf::from("/usr/bin/script"),
                    flavor: platform_script_flavor(),
                }),
                ProbeMode::Pipe,
            ]
        );
    }

    #[cfg(any(
        target_os = "macos",
        target_os = "freebsd",
        target_os = "netbsd",
        target_os = "openbsd",
        target_os = "dragonfly"
    ))]
    #[test]
    fn platform_script_flavor_is_bsd_on_bsd_targets() {
        assert_eq!(platform_script_flavor(), ScriptFlavor::Bsd);
    }

    #[cfg(not(any(
        target_os = "macos",
        target_os = "freebsd",
        target_os = "netbsd",
        target_os = "openbsd",
        target_os = "dragonfly"
    )))]
    #[test]
    fn platform_script_flavor_is_util_linux_on_other_targets() {
        assert_eq!(platform_script_flavor(), ScriptFlavor::UtilLinux);
    }

    #[test]
    fn cli_usage_parser_ignores_fable_weekly_subwindow() {
        let usage = parse_cli_usage_output(
            "Current session\n\
             98%used\n\
             Resets6:20am(Asia/Taipei)\n\
             Current week (all models)\n\
             67% used\n\
             Resets Jul 12, 9pm (Asia/Taipei)\n\
             Current week (Fable)\n\
             12% used\n\
             Resets Jul 12, 8:59pm (Asia/Taipei)\n",
        )
        .expect("usage");

        let five = usage.five_hour.expect("five hour");
        assert_eq!(five.used_percent, 98.0);
        assert_eq!(five.resets_at.as_deref(), Some("6:20am(Asia/Taipei)"));

        let weekly = usage.seven_day.expect("weekly");
        assert_eq!(weekly.used_percent, 67.0);
        assert_eq!(
            weekly.resets_at.as_deref(),
            Some("Jul 12, 9pm (Asia/Taipei)")
        );
    }
}
