//! Renders account token usage summaries and activity charts for `/usage`.
//!
//! This module owns the chart data bucketing and ratatui line construction. The
//! async card lifecycle stays in the parent `tokens` module so chart rendering
//! remains a pure transformation from a loaded usage response.

mod palette;

use std::collections::BTreeMap;

use chrono::Datelike;
use chrono::Duration;
use chrono::NaiveDate;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;

use crate::render::highlight::foreground_style_for_scopes;
use crate::status::format_tokens_compact;
use palette::TokenActivityPalette;

const WEEK_COUNT: usize = 52;
const DAY_COUNT: usize = 7;
const CELL_COUNT: usize = WEEK_COUNT * DAY_COUNT;
const CHART_LEFT_WIDTH: usize = 4;
const SUMMARY_INDENT: &str = " ";
const SUMMARY_INDENT_WIDTH: u16 = 1;

/// Selects the aggregation represented by the token activity chart.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::chatwidget) enum TokenActivityView {
    Daily,
    Weekly,
    Cumulative,
}

impl TokenActivityView {
    /// Parses the optional `/usage` argument into a supported chart view.
    ///
    /// An empty argument selects the daily view so `/usage` and `/usage daily`
    /// behave identically. Returning `None` lets the slash-command dispatcher
    /// report unsupported arguments instead of silently choosing a view.
    pub(in crate::chatwidget) fn parse(value: &str) -> Option<Self> {
        match value.trim().to_ascii_lowercase().as_str() {
            "" | "day" | "daily" => Some(Self::Daily),
            "week" | "weekly" => Some(Self::Weekly),
            "cumulative" => Some(Self::Cumulative),
            _ => None,
        }
    }

    pub(super) fn label(self) -> &'static str {
        match self {
            Self::Daily => "Daily",
            Self::Weekly => "Weekly",
            Self::Cumulative => "Cumulative",
        }
    }
}

pub(super) fn loaded_lines(
    view: TokenActivityView,
    response: &GetAccountTokenUsageResponse,
    today: NaiveDate,
    width: u16,
) -> Vec<Line<'static>> {
    let mut lines = vec![
        vec![
            Span::from(" Token activity").bold(),
            Span::styled("   last 12 months", label_style()),
        ]
        .into(),
    ];
    lines.extend(summary_lines(response, graph_width(width)));
    // Separate the headline numbers from the calendar below.
    lines.push(Line::default());
    let Some(buckets) = response.daily_usage_buckets.as_ref() else {
        lines.push("   Token activity history unavailable".dim().into());
        return lines;
    };

    lines.extend(chart_lines(view, buckets, today, width));
    lines
}

fn chart_lines(
    view: TokenActivityView,
    buckets: &[codex_app_server_protocol::AccountTokenUsageDailyBucket],
    today: NaiveDate,
    width: u16,
) -> Vec<Line<'static>> {
    let mut lines = Vec::new();
    let values = daily_values(buckets, today);
    let shown_columns = shown_columns(width);
    if shown_columns == 0 {
        lines.push("   Widen terminal to show activity graph".dim().into());
        return lines;
    }

    let palette = TokenActivityPalette::current();
    let levels = levels_for_view(&values, view);
    let first_column = WEEK_COUNT - shown_columns;
    lines.push(month_labels(today, first_column, shown_columns));
    for row in 0..DAY_COUNT {
        let mut spans = vec![weekday_label(view, row)];
        for column in first_column..WEEK_COUNT {
            if column > first_column {
                spans.push(" ".into());
            }
            let index = column * DAY_COUNT + row;
            if view == TokenActivityView::Daily
                && cell_date(today, index).is_some_and(|date| date > today)
            {
                spans.push(" ".into());
            } else {
                let style = if view == TokenActivityView::Daily {
                    palette.for_level(levels[index])
                } else {
                    palette.for_bar_level(levels[index])
                };
                spans.push(Span::styled(palette.glyph(view, levels[index]), style));
            }
        }
        lines.push(spans.into());
    }
    // Separate the calendar from the legend/footer below.
    lines.push(Line::default());
    match view {
        TokenActivityView::Daily => lines.push(legend_line(&palette)),
        TokenActivityView::Weekly | TokenActivityView::Cumulative => {
            lines.push(bar_caption(view, &values))
        }
    }
    lines.push(view_footer(view));
    lines
}

fn shown_columns(width: u16) -> usize {
    (usize::from(width)
        .saturating_sub(CHART_LEFT_WIDTH)
        .saturating_add(/*rhs*/ 1)
        / 2)
    .min(WEEK_COUNT)
}

fn graph_width(width: u16) -> u16 {
    if width == u16::MAX {
        return width;
    }
    (CHART_LEFT_WIDTH + shown_columns(width) * 2 - 1) as u16
}

fn summary_lines(response: &GetAccountTokenUsageResponse, width: u16) -> Vec<Line<'static>> {
    let summary = &response.summary;
    let fields = [
        ("Lifetime", format_optional_tokens(summary.lifetime_tokens)),
        ("Peak", format_optional_tokens(summary.peak_daily_tokens)),
        (
            "Streak",
            format_streak(summary.current_streak_days, summary.longest_streak_days),
        ),
        (
            "Longest task",
            format_optional_duration(summary.longest_running_turn_sec),
        ),
    ];
    pack_fields(&fields, width)
        .into_iter()
        .map(|group| align_summary_line(summary_line(&fields, &group), width))
        .collect()
}

/// Greedily pack summary fields into as few lines as fit `width`,
/// keeping field order. `u16::MAX` (raw/copy mode) always yields one line.
fn pack_fields(fields: &[(&str, String)], width: u16) -> Vec<Vec<usize>> {
    if width == u16::MAX {
        return vec![(0..fields.len()).collect()];
    }
    let max = usize::from(width.saturating_sub(SUMMARY_INDENT_WIDTH));
    let mut groups: Vec<Vec<usize>> = Vec::new();
    let mut current: Vec<usize> = Vec::new();
    for index in 0..fields.len() {
        let mut candidate = current.clone();
        candidate.push(index);
        if !current.is_empty() && summary_line(fields, &candidate).width() > max {
            groups.push(std::mem::take(&mut current));
            current.push(index);
        } else {
            current = candidate;
        }
    }
    if !current.is_empty() {
        groups.push(current);
    }
    groups
}

fn summary_line(fields: &[(&str, String)], indexes: &[usize]) -> Line<'static> {
    let mut spans = Vec::new();
    for (index, field_index) in indexes.iter().enumerate() {
        if index > 0 {
            spans.push(Span::styled(" · ", label_style()));
        }
        let (label, value) = &fields[*field_index];
        spans.push(Span::styled(format!("{label} "), label_style()));
        spans.push(Span::styled(value.clone(), numeric_style()));
    }
    spans.into()
}

fn align_summary_line(mut line: Line<'static>, width: u16) -> Line<'static> {
    if width == u16::MAX {
        return line;
    }
    line.spans.insert(/*index*/ 0, SUMMARY_INDENT.into());
    line
}

fn format_optional_tokens(value: Option<i64>) -> String {
    value
        .map(format_tokens_compact)
        .unwrap_or_else(|| "-".to_string())
}

/// Combine the current and longest streak into one field: a bare `54d` when
/// they match, otherwise `12d (best 54d)`.
fn format_streak(current: Option<i64>, longest: Option<i64>) -> String {
    match (current, longest) {
        (Some(current), Some(longest)) if current == longest => format!("{current}d"),
        (Some(current), Some(longest)) => format!("{current}d (best {longest}d)"),
        (Some(current), None) => format!("{current}d"),
        (None, Some(longest)) => format!("- (best {longest}d)"),
        (None, None) => "-".to_string(),
    }
}

fn format_optional_duration(value: Option<i64>) -> String {
    value.map_or_else(
        || "-".to_string(),
        |seconds| {
            let seconds = seconds.max(/*other*/ 0);
            let hours = seconds / 3600;
            let minutes = (seconds % 3600) / 60;
            match (hours, minutes) {
                (0, 0) => format!("{seconds}s"),
                (0, minutes) => format!("{minutes}m"),
                (hours, 0) => format!("{hours}h"),
                (hours, minutes) => format!("{hours}h {minutes}m"),
            }
        },
    )
}

fn numeric_style() -> Style {
    foreground_style_for_scopes(&["constant.numeric", "constant"])
        .unwrap_or_else(|| Style::default().green())
}

fn label_style() -> Style {
    foreground_style_for_scopes(&["comment"]).unwrap_or_else(|| Style::default().dim())
}

fn weekday_label(view: TokenActivityView, row: usize) -> Span<'static> {
    if view != TokenActivityView::Daily {
        // Bar views fill from the bottom (row 6) upward, so the gutter doubles
        // as a coarse Y-axis: peak at the top, baseline at the bottom.
        return Span::styled(
            match row {
                0 => "max ",
                6 => "  0 ",
                _ => "    ",
            },
            label_style(),
        );
    }
    Span::styled(
        match row {
            0 => " Su ",
            1 => " Mo ",
            2 => " Tu ",
            3 => " We ",
            4 => " Th ",
            5 => " Fr ",
            6 => " Sa ",
            _ => "    ",
        },
        label_style(),
    )
}

fn legend_line(palette: &TokenActivityPalette) -> Line<'static> {
    let mut spans = vec![Span::styled("   Less ", label_style())];
    for level in 0..=4 {
        if level > 0 {
            spans.push(" ".into());
        }
        spans.push(Span::styled(
            palette.glyph(TokenActivityView::Daily, level),
            palette.for_level(level),
        ));
    }
    spans.push(Span::styled(" More", label_style()));
    spans.into()
}

/// Caption for the bar-chart views, where the 5-step daily legend would be
/// misleading. States what each bar represents and the peak it is scaled to.
fn bar_caption(view: TokenActivityView, values: &[i64]) -> Line<'static> {
    let weeks = weekly_totals(values);
    let (lead, peak) = match view {
        TokenActivityView::Weekly => (
            "Each column = 1 week · tallest ",
            weeks.iter().copied().max().unwrap_or(/*default*/ 0),
        ),
        TokenActivityView::Cumulative => ("Running total · top ", weeks.iter().sum::<i64>()),
        TokenActivityView::Daily => ("", 0),
    };
    if peak <= 0 {
        return Span::styled("   No token activity in the last 12 months", label_style()).into();
    }
    vec![
        Span::styled(format!("   {lead}"), label_style()),
        Span::styled(format_tokens_compact(peak), numeric_style()),
    ]
    .into()
}

/// Dim footer that surfaces the other `/usage` views and emphasizes the
/// active one, so the weekly/cumulative modes are discoverable from the card.
fn view_footer(active: TokenActivityView) -> Line<'static> {
    let mut spans = vec![Span::styled("   ", label_style())];
    let views = [
        (TokenActivityView::Daily, "daily"),
        (TokenActivityView::Weekly, "weekly"),
        (TokenActivityView::Cumulative, "cumulative"),
    ];
    for (index, (view, name)) in views.into_iter().enumerate() {
        if index > 0 {
            spans.push(Span::styled(" · ", label_style()));
        }
        let style = if view == active {
            numeric_style().bold()
        } else {
            label_style()
        };
        spans.push(Span::styled(name, style));
    }
    spans.into()
}

fn month_labels(today: NaiveDate, first_column: usize, shown_columns: usize) -> Line<'static> {
    let mut cells = vec![' '; shown_columns * 2 - 1];
    let start = chart_start(today);
    let mut last_end = 0;
    for column in first_column..WEEK_COUNT {
        let date = start + Duration::days((column * DAY_COUNT) as i64);
        if date.day() > 7 {
            continue;
        }
        let label = date.format("%b").to_string();
        let offset = (column - first_column) * 2;
        if offset < last_end || offset + label.len() > cells.len() {
            continue;
        }
        for (index, ch) in label.chars().enumerate() {
            cells[offset + index] = ch;
        }
        last_end = offset + label.len() + 1;
    }
    vec![
        "    ".into(),
        Span::styled(cells.into_iter().collect::<String>(), label_style()),
    ]
    .into()
}

/// Normalizes backend daily buckets into the fixed 52-week display window.
///
/// The returned vector is ordered by chart cell, starting with the oldest Sunday.
/// Invalid, out-of-window, and future dates are ignored. Duplicate dates are
/// accumulated and negative token values do not reduce activity.
fn daily_values(
    buckets: &[codex_app_server_protocol::AccountTokenUsageDailyBucket],
    today: NaiveDate,
) -> Vec<i64> {
    let start = chart_start(today);
    let end = start + Duration::days(CELL_COUNT as i64);
    let mut by_date = BTreeMap::new();
    for bucket in buckets {
        let Ok(date) = NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d") else {
            continue;
        };
        if date < start || date >= end || date > today {
            continue;
        }
        *by_date.entry(date).or_insert(/*default*/ 0) += bucket.tokens.max(/*other*/ 0);
    }
    (0..CELL_COUNT)
        .map(|offset| {
            by_date
                .get(&(start + Duration::days(offset as i64)))
                .copied()
                .unwrap_or(/*default*/ 0)
        })
        .collect()
}

fn levels_for_view(values: &[i64], view: TokenActivityView) -> Vec<usize> {
    match view {
        TokenActivityView::Daily => graded_levels(values),
        TokenActivityView::Weekly => bar_levels(&weekly_totals(values)),
        TokenActivityView::Cumulative => {
            let cumulative = weekly_totals(values)
                .into_iter()
                .scan(/*initial_state*/ 0, |sum, value| {
                    *sum += value;
                    Some(*sum)
                })
                .collect::<Vec<_>>();
            bar_levels(&cumulative)
        }
    }
}

fn graded_levels(values: &[i64]) -> Vec<usize> {
    let max = values.iter().copied().max().unwrap_or(/*default*/ 0);
    values
        .iter()
        .map(|value| match (*value, max) {
            (0, _) | (_, 0) => 0,
            (value, max) if value * 4 > max * 3 => 4,
            (value, max) if value * 2 > max => 3,
            (value, max) if value * 4 > max => 2,
            _ => 1,
        })
        .collect()
}

fn weekly_totals(values: &[i64]) -> Vec<i64> {
    values
        .chunks(DAY_COUNT)
        .map(|week| week.iter().sum())
        .collect()
}

fn bar_levels(totals: &[i64]) -> Vec<usize> {
    let max = totals.iter().copied().max().unwrap_or(/*default*/ 0);
    totals
        .iter()
        .flat_map(|value| {
            let height = if *value <= 0 || max <= 0 {
                0
            } else {
                ((*value * DAY_COUNT as i64 + max - 1) / max) as usize
            };
            (0..DAY_COUNT).map(move |row| if DAY_COUNT - row <= height { 4 } else { 0 })
        })
        .collect()
}

fn chart_start(today: NaiveDate) -> NaiveDate {
    let week_start = today - Duration::days(i64::from(today.weekday().num_days_from_sunday()));
    week_start - Duration::weeks((WEEK_COUNT - 1) as i64)
}

fn cell_date(today: NaiveDate, index: usize) -> Option<NaiveDate> {
    chart_start(today).checked_add_signed(Duration::days(index as i64))
}

#[cfg(test)]
#[path = "chart_tests.rs"]
mod tests;
