//! Rate-limit and credits display shaping for status surfaces.
//!
//! This module maps `RateLimitSnapshot` protocol payloads into display-oriented rows that the TUI
//! can render in `/status` and status-line contexts without duplicating formatting logic.
//!
//! The key contract is that time-sensitive values are interpreted relative to a caller-provided
//! capture timestamp so stale detection and reset labels remain coherent for a given draw cycle.
use crate::chatwidget::fallback_limit_label;
use crate::chatwidget::limit_label_for_window;
use crate::text_formatting::capitalize_first;

use super::helpers::format_reset_timestamp;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Local;
use chrono::Utc;
use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot;
use codex_protocol::num_format::format_with_separators;

const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "█";
const STATUS_LIMIT_BAR_EMPTY: &str = "░";

#[derive(Debug, Clone)]
pub(crate) struct StatusRateLimitRow {
    /// Human-readable row label, such as `"5h limit"`, `"Monthly limit"`, or `"Credits"`.
    pub label: String,
    /// Value payload for the row.
    pub value: StatusRateLimitValue,
}

/// Display value variants for a single rate-limit row.
#[derive(Debug, Clone)]
pub(crate) enum StatusRateLimitValue {
    /// Percent-based usage window with optional reset timestamp text.
    Window {
        /// Percent of the window that has been consumed.
        percent_used: f64,
        /// Localized reset string, or `None` when unknown.
        resets_at: Option<String>,
        /// Optional detail line rendered beneath the progress bar.
        details: Option<String>,
    },
    /// Plain text value used for non-window rows.
    Text(String),
}

/// Availability state for rate-limit data shown in status output.
#[derive(Debug, Clone)]
pub(crate) enum StatusRateLimitData {
    /// Snapshot data is recent enough for normal rendering.
    Available(Vec<StatusRateLimitRow>),
    /// Snapshot data exists but is older than the staleness threshold.
    Stale(Vec<StatusRateLimitRow>),
    /// The refresh completed, but the response did not include displayable usage data.
    Unavailable,
    /// No snapshot data is currently available.
    Missing,
}

/// Maximum age before a snapshot is considered stale in status output.
pub(crate) const RATE_LIMIT_STALE_THRESHOLD_MINUTES: i64 = 15;

/// Display-friendly representation of one usage window from a snapshot.
#[derive(Debug, Clone)]
pub(crate) struct RateLimitWindowDisplay {
    /// Percent used for the window.
    pub used_percent: f64,
    /// Human-readable local reset time.
    pub resets_at: Option<String>,
    /// Window length in minutes when provided by the server.
    pub window_minutes: Option<i64>,
}

impl RateLimitWindowDisplay {
    fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
        let resets_at_utc = window
            .resets_at
            .and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0))
            .map(|dt| dt.with_timezone(&Local));
        let resets_at = resets_at_utc.map(|dt| format_reset_timestamp(dt, captured_at));

        Self {
            used_percent: f64::from(window.used_percent),
            resets_at,
            window_minutes: window.window_duration_mins,
        }
    }
}

#[derive(Debug, Clone)]
pub(crate) struct RateLimitSnapshotDisplay {
    /// Canonical limit identifier (for example: `codex` or `codex_other`).
    pub limit_name: String,
    /// Local timestamp representing when this display snapshot was captured.
    pub captured_at: DateTime<Local>,
    /// Primary usage window.
    pub primary: Option<RateLimitWindowDisplay>,
    /// Secondary usage window.
    pub secondary: Option<RateLimitWindowDisplay>,
    /// Optional credits metadata when available.
    pub credits: Option<CreditsSnapshotDisplay>,
    /// Optional effective monthly credit limit from workspace spend controls.
    pub individual_limit: Option<SpendControlLimitSnapshotDisplay>,
}

/// Display-ready credits state extracted from protocol snapshots.
#[derive(Debug, Clone)]
pub(crate) struct CreditsSnapshotDisplay {
    /// Whether credits tracking is enabled for the account.
    pub has_credits: bool,
    /// Whether the account has unlimited credits.
    pub unlimited: bool,
    /// Raw balance text as provided by the backend.
    pub balance: Option<String>,
}

/// Display-ready effective monthly limit extracted from spend controls.
#[derive(Debug, Clone)]
pub(crate) struct SpendControlLimitSnapshotDisplay {
    /// Local timestamp representing when this monthly usage value was captured.
    pub captured_at: DateTime<Local>,
    pub percent_remaining: f64,
    pub used: String,
    pub limit: String,
    pub resets_at: Option<String>,
}

/// Converts a protocol snapshot into UI-friendly display data.
///
/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly
/// older or newer `captured_at` can produce misleading reset labels and stale classification.
#[cfg(test)]
pub(crate) fn rate_limit_snapshot_display(
    snapshot: &RateLimitSnapshot,
    captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
    rate_limit_snapshot_display_for_limit(snapshot, "codex".to_string(), captured_at)
}

pub(crate) fn rate_limit_snapshot_display_for_limit(
    snapshot: &RateLimitSnapshot,
    limit_name: String,
    captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
    RateLimitSnapshotDisplay {
        limit_name,
        captured_at,
        primary: snapshot
            .primary
            .as_ref()
            .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
        secondary: snapshot
            .secondary
            .as_ref()
            .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
        credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
        individual_limit: snapshot
            .individual_limit
            .as_ref()
            .and_then(|limit| SpendControlLimitSnapshotDisplay::from_limit(limit, captured_at)),
    }
}

impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay {
    fn from(value: &CoreCreditsSnapshot) -> Self {
        Self {
            has_credits: value.has_credits,
            unlimited: value.unlimited,
            balance: value.balance.clone(),
        }
    }
}

impl SpendControlLimitSnapshotDisplay {
    fn from_limit(
        value: &CoreSpendControlLimitSnapshot,
        captured_at: DateTime<Local>,
    ) -> Option<Self> {
        Some(Self {
            captured_at,
            percent_remaining: f64::from(value.remaining_percent.clamp(0, 100)),
            used: format_credit_amount(&value.used)?,
            limit: format_credit_amount(&value.limit)?,
            resets_at: DateTime::<Utc>::from_timestamp(value.resets_at, 0)
                .map(|dt| format_reset_timestamp(dt.with_timezone(&Local), captured_at)),
        })
    }
}

/// Builds display rows from a snapshot and marks stale data by capture age.
///
/// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make
/// fresh data appear stale or prevent stale warnings from appearing.
pub(crate) fn compose_rate_limit_data(
    snapshot: Option<&RateLimitSnapshotDisplay>,
    now: DateTime<Local>,
) -> StatusRateLimitData {
    match snapshot {
        Some(snapshot) => compose_rate_limit_data_many(std::slice::from_ref(snapshot), now),
        None => StatusRateLimitData::Missing,
    }
}

pub(crate) fn compose_rate_limit_data_many(
    snapshots: &[RateLimitSnapshotDisplay],
    now: DateTime<Local>,
) -> StatusRateLimitData {
    if snapshots.is_empty() {
        return StatusRateLimitData::Missing;
    }

    let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3));
    let mut stale = false;

    for snapshot in snapshots {
        stale |= now.signed_duration_since(snapshot.captured_at)
            > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
        stale |= snapshot
            .individual_limit
            .as_ref()
            .map(|limit| {
                now.signed_duration_since(limit.captured_at)
                    > ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES)
            })
            .unwrap_or(false);

        let limit_bucket_label = snapshot.limit_name.clone();
        let show_limit_prefix = !limit_bucket_label.eq_ignore_ascii_case("codex");
        let primary_label = snapshot
            .primary
            .as_ref()
            .map(|window| {
                limit_label_for_window(window.window_minutes, /*is_secondary*/ false)
            })
            .map(|label| capitalize_first(&label));
        let secondary_label = snapshot
            .secondary
            .as_ref()
            .map(|window| limit_label_for_window(window.window_minutes, /*is_secondary*/ true))
            .map(|label| capitalize_first(&label));
        let window_count =
            usize::from(snapshot.primary.is_some()) + usize::from(snapshot.secondary.is_some());
        let combine_non_codex_single_limit = show_limit_prefix && window_count == 1;

        if show_limit_prefix && !combine_non_codex_single_limit {
            rows.push(StatusRateLimitRow {
                label: format!("{limit_bucket_label} limit"),
                value: StatusRateLimitValue::Text(String::new()),
            });
        }

        if let Some(primary) = snapshot.primary.as_ref() {
            let label = if combine_non_codex_single_limit {
                format!(
                    "{} {} limit",
                    limit_bucket_label,
                    primary_label.clone().unwrap_or_else(|| capitalize_first(
                        fallback_limit_label(/*is_secondary*/ false)
                    ))
                )
            } else {
                format!(
                    "{} limit",
                    primary_label.clone().unwrap_or_else(|| capitalize_first(
                        fallback_limit_label(/*is_secondary*/ false)
                    ))
                )
            };
            rows.push(StatusRateLimitRow {
                label,
                value: StatusRateLimitValue::Window {
                    percent_used: primary.used_percent,
                    resets_at: primary.resets_at.clone(),
                    details: None,
                },
            });
        }

        if let Some(secondary) = snapshot.secondary.as_ref() {
            let label = if combine_non_codex_single_limit {
                format!(
                    "{} {} limit",
                    limit_bucket_label,
                    secondary_label.clone().unwrap_or_else(|| capitalize_first(
                        fallback_limit_label(/*is_secondary*/ true)
                    ))
                )
            } else {
                format!(
                    "{} limit",
                    secondary_label.clone().unwrap_or_else(|| capitalize_first(
                        fallback_limit_label(/*is_secondary*/ true)
                    ))
                )
            };
            rows.push(StatusRateLimitRow {
                label,
                value: StatusRateLimitValue::Window {
                    percent_used: secondary.used_percent,
                    resets_at: secondary.resets_at.clone(),
                    details: None,
                },
            });
        }

        if let Some(credits) = snapshot.credits.as_ref()
            && let Some(row) = credit_status_row(credits)
        {
            rows.push(row);
        }
        if let Some(individual_limit) = snapshot.individual_limit.as_ref() {
            rows.push(StatusRateLimitRow {
                label: "Monthly credit limit".to_string(),
                value: StatusRateLimitValue::Window {
                    percent_used: 100.0 - individual_limit.percent_remaining,
                    resets_at: individual_limit.resets_at.clone(),
                    details: Some(format!(
                        "{} of {} credits used",
                        individual_limit.used, individual_limit.limit
                    )),
                },
            });
        }
    }

    if rows.is_empty() {
        StatusRateLimitData::Unavailable
    } else if stale {
        StatusRateLimitData::Stale(rows)
    } else {
        StatusRateLimitData::Available(rows)
    }
}

/// Renders a fixed-width progress bar from remaining percentage.
///
/// This function expects a remaining value in the `0..=100` range and clamps out-of-range input.
/// Passing a used percentage by mistake will invert the bar and mislead users.
pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String {
    let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0);
    let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
    let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
    let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
    format!(
        "[{}{}]",
        STATUS_LIMIT_BAR_FILLED.repeat(filled),
        STATUS_LIMIT_BAR_EMPTY.repeat(empty)
    )
}

/// Formats a compact textual summary from remaining percentage.
pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String {
    format!("{percent_remaining:.0}% left")
}

/// Builds a single `StatusRateLimitRow` for credits when the snapshot indicates
/// that the account has credit tracking enabled. When credits are unlimited we
/// show that fact explicitly; otherwise we render the rounded balance in
/// credits. Accounts with credits = 0 skip this section entirely.
fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option<StatusRateLimitRow> {
    if !credits.has_credits {
        return None;
    }
    if credits.unlimited {
        return Some(StatusRateLimitRow {
            label: "Credits".to_string(),
            value: StatusRateLimitValue::Text("Unlimited".to_string()),
        });
    }
    let balance = credits.balance.as_ref()?;
    let display_balance = format_credit_balance(balance)?;
    Some(StatusRateLimitRow {
        label: "Credits".to_string(),
        value: StatusRateLimitValue::Text(format!("{display_balance} credits")),
    })
}

fn format_credit_balance(raw: &str) -> Option<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return None;
    }

    if let Ok(int_value) = trimmed.parse::<i64>()
        && int_value > 0
    {
        return Some(int_value.to_string());
    }

    if let Ok(value) = trimmed.parse::<f64>()
        && value > 0.0
    {
        let rounded = value.round() as i64;
        return Some(rounded.to_string());
    }

    None
}

fn format_credit_amount(raw: &str) -> Option<String> {
    let value = raw.trim().parse::<f64>().ok()?;
    if !value.is_finite() || value < 0.0 {
        return None;
    }
    Some(format_with_separators(value.round() as i64))
}

#[cfg(test)]
mod tests {
    use super::CreditsSnapshotDisplay;
    use super::RateLimitSnapshotDisplay;
    use super::RateLimitWindowDisplay;
    use super::StatusRateLimitData;
    use super::compose_rate_limit_data_many;
    use chrono::Local;
    use pretty_assertions::assert_eq;

    fn window(used_percent: f64) -> RateLimitWindowDisplay {
        RateLimitWindowDisplay {
            used_percent,
            resets_at: Some("soon".to_string()),
            window_minutes: Some(300),
        }
    }

    #[test]
    fn non_codex_single_limit_renders_combined_row() {
        let now = Local::now();
        let codex = RateLimitSnapshotDisplay {
            limit_name: "codex".to_string(),
            captured_at: now,
            primary: Some(window(/*used_percent*/ 10.0)),
            secondary: None,
            credits: Some(CreditsSnapshotDisplay {
                has_credits: true,
                unlimited: false,
                balance: Some("25".to_string()),
            }),
            individual_limit: None,
        };
        let other = RateLimitSnapshotDisplay {
            limit_name: "codex-other".to_string(),
            captured_at: now,
            primary: Some(window(/*used_percent*/ 20.0)),
            secondary: None,
            credits: Some(CreditsSnapshotDisplay {
                has_credits: true,
                unlimited: false,
                balance: Some("99".to_string()),
            }),
            individual_limit: None,
        };

        let rows = match compose_rate_limit_data_many(&[codex, other], now) {
            StatusRateLimitData::Available(rows) => rows,
            other => panic!("unexpected status: {other:?}"),
        };

        let labels: Vec<String> = rows.iter().map(|row| row.label.clone()).collect();
        assert_eq!(
            labels,
            vec![
                "5h limit".to_string(),
                "Credits".to_string(),
                "codex-other 5h limit".to_string(),
                "Credits".to_string(),
            ]
        );
        assert_eq!(rows.iter().filter(|row| row.label == "Credits").count(), 2);
    }

    #[test]
    fn non_codex_multi_limit_keeps_group_row() {
        let now = Local::now();
        let other = RateLimitSnapshotDisplay {
            limit_name: "codex-other".to_string(),
            captured_at: now,
            primary: Some(RateLimitWindowDisplay {
                used_percent: 20.0,
                resets_at: Some("soon".to_string()),
                window_minutes: Some(60),
            }),
            secondary: Some(RateLimitWindowDisplay {
                used_percent: 40.0,
                resets_at: Some("later".to_string()),
                window_minutes: Some(2 * 60),
            }),
            credits: None,
            individual_limit: None,
        };

        let rows = match compose_rate_limit_data_many(&[other], now) {
            StatusRateLimitData::Available(rows) => rows,
            other => panic!("unexpected status: {other:?}"),
        };
        let labels: Vec<String> = rows.iter().map(|row| row.label.clone()).collect();
        assert_eq!(
            labels,
            vec![
                "codex-other limit".to_string(),
                "Usage limit".to_string(),
                "Secondary usage limit".to_string(),
            ]
        );
    }
}
