// Copyright 2025 Lablup Inc. and Jeongkyu Shin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Renderers for `all-smi config print`. Kept here so the command glue
//! and the schema stay co-located; serialization uses a minimal
//! hand-written projection that avoids polluting the `Settings` type
//! with `Serialize` derives.

use serde_json::{Value as JsonValue, json};
use std::fmt::Write;

use crate::common::config_file::{Settings, SocketSetting};

/// Convert a [`Settings`] into a TOML string that parses back into an
/// equivalent `Settings`. Redacts `webhook_url` unless `show_secrets`
/// is set.
pub fn render_toml(settings: &Settings, show_secrets: bool) -> std::io::Result<String> {
    let mut out = String::new();
    let webhook = if show_secrets || settings.alerts.webhook_url.is_empty() {
        settings.alerts.webhook_url.clone()
    } else {
        "<redacted>".to_string()
    };

    writeln!(
        &mut out,
        "schema_version = {}",
        crate::common::config_file::SUPPORTED_SCHEMA_VERSION
    )
    .ok();
    writeln!(&mut out).ok();

    writeln!(&mut out, "[general]").ok();
    writeln!(
        &mut out,
        "default_mode = \"{}\"",
        escape_toml(&settings.general.default_mode)
    )
    .ok();
    writeln!(
        &mut out,
        "theme = \"{}\"",
        escape_toml(&settings.general.theme)
    )
    .ok();
    writeln!(
        &mut out,
        "locale = \"{}\"",
        escape_toml(&settings.general.locale)
    )
    .ok();
    writeln!(&mut out).ok();

    writeln!(&mut out, "[local]").ok();
    if let Some(v) = settings.local.interval_secs {
        writeln!(&mut out, "interval_secs = {v}").ok();
    }
    writeln!(&mut out).ok();

    writeln!(&mut out, "[view]").ok();
    if let Some(h) = &settings.view.hostfile {
        writeln!(&mut out, "hostfile = \"{}\"", escape_toml(h)).ok();
    }
    if !settings.view.hosts.is_empty() {
        write!(&mut out, "hosts = [").ok();
        for (i, h) in settings.view.hosts.iter().enumerate() {
            if i > 0 {
                write!(&mut out, ", ").ok();
            }
            write!(&mut out, "\"{}\"", escape_toml(h)).ok();
        }
        writeln!(&mut out, "]").ok();
    }
    if let Some(v) = settings.view.interval_secs {
        writeln!(&mut out, "interval_secs = {v}").ok();
    }
    writeln!(&mut out).ok();

    writeln!(&mut out, "[api]").ok();
    writeln!(&mut out, "port = {}", settings.api.port).ok();
    write!(&mut out, "socket = ").ok();
    match &settings.api.socket {
        SocketSetting::Unset | SocketSetting::Bool(false) => writeln!(&mut out, "false").ok(),
        SocketSetting::Bool(true) => writeln!(&mut out, "true").ok(),
        SocketSetting::Path(p) => writeln!(&mut out, "\"{}\"", escape_toml(p)).ok(),
    };
    writeln!(&mut out, "processes = {}", settings.api.processes).ok();
    writeln!(&mut out, "interval_secs = {}", settings.api.interval_secs).ok();
    writeln!(&mut out).ok();

    writeln!(&mut out, "[alerts]").ok();
    writeln!(&mut out, "temp_warn_c = {}", settings.alerts.temp_warn_c).ok();
    writeln!(&mut out, "temp_crit_c = {}", settings.alerts.temp_crit_c).ok();
    writeln!(
        &mut out,
        "util_idle_pct = {}",
        settings.alerts.util_idle_pct
    )
    .ok();
    writeln!(
        &mut out,
        "util_idle_warn_mins = {}",
        settings.alerts.util_idle_warn_mins
    )
    .ok();
    writeln!(&mut out, "hysteresis_c = {}", settings.alerts.hysteresis_c).ok();
    writeln!(
        &mut out,
        "bell_on_critical = {}",
        settings.alerts.bell_on_critical
    )
    .ok();
    writeln!(&mut out, "power_crit_w = {}", settings.alerts.power_crit_w).ok();
    writeln!(&mut out, "webhook_url = \"{}\"", escape_toml(&webhook)).ok();
    writeln!(&mut out).ok();

    writeln!(&mut out, "[energy]").ok();
    writeln!(
        &mut out,
        "price_per_kwh = {}",
        format_float(settings.energy.price_per_kwh)
    )
    .ok();
    writeln!(
        &mut out,
        "currency = \"{}\"",
        escape_toml(&settings.energy.currency)
    )
    .ok();
    writeln!(&mut out, "show_cost = {}", settings.energy.show_cost).ok();
    // Render the operator-supplied override verbatim when present; when
    // the field is `None` (issue #229: default = "use platform cache
    // helper") render the resolved path so `config print` always shows
    // exactly where the WAL lands. Falls back to an empty string in the
    // unlikely event no home-like directory is resolvable.
    let wal_path_rendered = match &settings.energy.wal_path {
        Some(s) => s.clone(),
        None => crate::metrics::energy_wal::resolve_wal_path(None)
            .map(|p| p.display().to_string())
            .unwrap_or_default(),
    };
    writeln!(
        &mut out,
        "wal_path = \"{}\"",
        escape_toml(&wal_path_rendered)
    )
    .ok();
    writeln!(
        &mut out,
        "gap_interpolate_seconds = {}",
        settings.energy.gap_interpolate_seconds
    )
    .ok();
    writeln!(&mut out, "wal_enabled = {}", settings.energy.wal_enabled).ok();
    writeln!(&mut out).ok();

    writeln!(&mut out, "[display]").ok();
    writeln!(
        &mut out,
        "color_scheme = \"{}\"",
        escape_toml(&settings.display.color_scheme)
    )
    .ok();
    writeln!(
        &mut out,
        "gauge_style = \"{}\"",
        escape_toml(&settings.display.gauge_style)
    )
    .ok();
    writeln!(
        &mut out,
        "show_led_grid = {}",
        settings.display.show_led_grid
    )
    .ok();
    writeln!(&mut out).ok();

    writeln!(&mut out, "[record]").ok();
    // Render the operator-supplied override verbatim when present;
    // otherwise render the resolved platform cache path so `config
    // print` reflects exactly where recordings land (issue #229).
    let output_dir_rendered = match &settings.record.output_dir {
        Some(s) => s.clone(),
        None => crate::common::paths::cache_dir()
            .map(|d| d.join("records").display().to_string())
            .unwrap_or_default(),
    };
    writeln!(
        &mut out,
        "output_dir = \"{}\"",
        escape_toml(&output_dir_rendered)
    )
    .ok();
    writeln!(
        &mut out,
        "compress = \"{}\"",
        escape_toml(&settings.record.compress)
    )
    .ok();
    writeln!(&mut out).ok();

    writeln!(&mut out, "[snapshot]").ok();
    writeln!(
        &mut out,
        "default_format = \"{}\"",
        escape_toml(&settings.snapshot.default_format)
    )
    .ok();
    writeln!(
        &mut out,
        "default_pretty = {}",
        settings.snapshot.default_pretty
    )
    .ok();

    Ok(out)
}

/// JSON projection for `config print --format json`. Mirrors the TOML
/// renderer structure but flattened so downstream `jq` pipelines are
/// predictable.
pub fn render_json(settings: &Settings, show_secrets: bool) -> std::io::Result<String> {
    let webhook = if show_secrets || settings.alerts.webhook_url.is_empty() {
        JsonValue::String(settings.alerts.webhook_url.clone())
    } else {
        JsonValue::String("<redacted>".to_string())
    };

    let socket_value = match &settings.api.socket {
        SocketSetting::Unset | SocketSetting::Bool(false) => JsonValue::Bool(false),
        SocketSetting::Bool(true) => JsonValue::Bool(true),
        SocketSetting::Path(p) => JsonValue::String(p.clone()),
    };

    let doc = json!({
        "schema_version": crate::common::config_file::SUPPORTED_SCHEMA_VERSION,
        "general": {
            "default_mode": settings.general.default_mode,
            "theme": settings.general.theme,
            "locale": settings.general.locale,
        },
        "local": {
            "interval_secs": settings.local.interval_secs,
        },
        "view": {
            "hostfile": settings.view.hostfile,
            "hosts": settings.view.hosts,
            "interval_secs": settings.view.interval_secs,
        },
        "api": {
            "port": settings.api.port,
            "socket": socket_value,
            "processes": settings.api.processes,
            "interval_secs": settings.api.interval_secs,
        },
        "alerts": {
            "temp_warn_c": settings.alerts.temp_warn_c,
            "temp_crit_c": settings.alerts.temp_crit_c,
            "util_idle_pct": settings.alerts.util_idle_pct,
            "util_idle_warn_mins": settings.alerts.util_idle_warn_mins,
            "hysteresis_c": settings.alerts.hysteresis_c,
            "bell_on_critical": settings.alerts.bell_on_critical,
            "power_crit_w": settings.alerts.power_crit_w,
            "webhook_url": webhook,
        },
        "energy": {
            "price_per_kwh": settings.energy.price_per_kwh,
            "currency": settings.energy.currency,
            "show_cost": settings.energy.show_cost,
            // `wal_path` is `None` when the operator did not override
            // the platform default (issue #229) — render the resolved
            // on-disk path so JSON consumers see exactly where the WAL
            // lands rather than a sentinel `null`.
            "wal_path": match &settings.energy.wal_path {
                Some(s) => s.clone(),
                None => crate::metrics::energy_wal::resolve_wal_path(None)
                    .map(|p| p.display().to_string())
                    .unwrap_or_default(),
            },
            "gap_interpolate_seconds": settings.energy.gap_interpolate_seconds,
            "wal_enabled": settings.energy.wal_enabled,
        },
        "display": {
            "color_scheme": settings.display.color_scheme,
            "gauge_style": settings.display.gauge_style,
            "show_led_grid": settings.display.show_led_grid,
        },
        "record": {
            // `output_dir` is `None` when the operator did not override
            // the platform default (issue #229) — render the resolved
            // on-disk path so JSON consumers see where recordings land.
            "output_dir": match &settings.record.output_dir {
                Some(s) => s.clone(),
                None => crate::common::paths::cache_dir()
                    .map(|d| d.join("records").display().to_string())
                    .unwrap_or_default(),
            },
            "compress": settings.record.compress,
        },
        "snapshot": {
            "default_format": settings.snapshot.default_format,
            "default_pretty": settings.snapshot.default_pretty,
        },
        "source_path": settings.source_path.as_ref().map(|p| p.display().to_string()),
    });
    serde_json::to_string_pretty(&doc)
        .map_err(|e| std::io::Error::other(format!("json render: {e}")))
}

/// Escape a string so it is valid inside a TOML basic string literal.
///
/// Covers the full set required by the TOML 1.0 grammar:
/// * `\` and `"` (mandatory — otherwise the string terminates early).
/// * `\n`, `\r`, `\t`, `\b`, `\f` (control characters with shorthand
///   escapes; without them the rendered file would contain raw control
///   bytes that most TOML parsers reject and that fail to round-trip
///   through [`parse_toml`](crate::common::config_file::parse_toml)).
/// * Any other `U+0000`–`U+001F` codepoint and `U+007F`, emitted as
///   `\u{XXXX}`. A `webhook_url` or `wal_path` set via the TOML file
///   could contain arbitrary bytes; without this branch the renderer
///   would emit literal control characters that break the round-trip
///   test (and potentially the parser on reload).
fn escape_toml(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{0008}' => out.push_str("\\b"),
            '\u{000c}' => out.push_str("\\f"),
            c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
                // TOML 1.0 spec: `\uXXXX` uses 4 hex digits with no
                // braces (distinct from Rust's `\u{XXXX}` syntax).
                out.push_str(&format!("\\u{:04X}", c as u32));
            }
            c => out.push(c),
        }
    }
    out
}

fn format_float(f: f64) -> String {
    if f.is_finite() {
        // `{}` prints integers without a decimal point; TOML requires at
        // least one digit after `.` for a float. Force a decimal.
        let s = format!("{f}");
        if s.contains('.') { s } else { format!("{s}.0") }
    } else {
        // Non-finite floats are not valid TOML; emit 0 instead of
        // producing an unparseable file.
        "0.0".to_string()
    }
}

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

    #[test]
    fn render_toml_round_trips_through_loader() {
        let mut s = Settings::default();
        s.api.port = 9191;
        s.alerts.temp_warn_c = 77;
        s.alerts.webhook_url = "https://hooks.example/bot".to_string();

        let toml_str = render_toml(&s, true).unwrap();
        let (raw, unknown) = crate::common::config_file::parse_toml(&toml_str).unwrap();
        assert!(unknown.is_empty(), "rendered toml should be clean");
        assert_eq!(raw.api.as_ref().and_then(|a| a.port), Some(9191));
        assert_eq!(raw.alerts.as_ref().and_then(|a| a.temp_warn_c), Some(77));
        assert_eq!(
            raw.alerts.as_ref().and_then(|a| a.webhook_url.clone()),
            Some("https://hooks.example/bot".to_string())
        );
    }

    #[test]
    fn render_toml_redacts_webhook_by_default() {
        let mut s = Settings::default();
        s.alerts.webhook_url = "https://hooks.example/bot-secret".to_string();
        let toml_str = render_toml(&s, false).unwrap();
        assert!(toml_str.contains("<redacted>"));
        assert!(!toml_str.contains("bot-secret"));
    }

    #[test]
    fn render_json_outputs_valid_json() {
        let s = Settings::default();
        let json_str = render_json(&s, false).unwrap();
        let _: JsonValue = serde_json::from_str(&json_str).expect("valid JSON");
    }

    #[test]
    fn render_json_redacts_webhook_by_default() {
        let mut s = Settings::default();
        s.alerts.webhook_url = "https://hooks.example/secret".to_string();
        let json_str = render_json(&s, false).unwrap();
        assert!(json_str.contains("<redacted>"));
        assert!(!json_str.contains("secret"));
        let json_str = render_json(&s, true).unwrap();
        assert!(json_str.contains("secret"));
    }

    #[test]
    fn format_float_preserves_dot() {
        assert_eq!(format_float(0.12), "0.12");
        assert_eq!(format_float(1.0), "1.0");
        assert_eq!(format_float(42.0), "42.0");
    }

    #[test]
    fn format_float_handles_non_finite() {
        assert_eq!(format_float(f64::NAN), "0.0");
        assert_eq!(format_float(f64::INFINITY), "0.0");
    }

    /// A `webhook_url` that contains newlines, tabs, or ESC sequences
    /// must round-trip through `render_toml` -> `parse_toml` without
    /// losing bytes or producing an invalid TOML document. Prior to the
    /// full escape set the renderer emitted raw `\n` / `\r` / `\t`
    /// characters, which either broke the string (newline) or
    /// silently got stripped (tab, BEL, ESC) — either way breaking
    /// round-trip.
    #[test]
    fn render_toml_roundtrips_control_characters() {
        let mut s = Settings::default();
        // Exercise every escape branch: `\n`, `\t`, `\r`, `\b`, `\f`,
        // and a representative `\uXXXX` (ESC = 0x1b).
        let nasty = "line1\nline2\twith\rtab\x08back\x0cff\x1b[31mRED\x7f";
        s.alerts.webhook_url = nasty.to_string();

        let toml_str = render_toml(&s, true).unwrap();
        let (raw, unknown) = crate::common::config_file::parse_toml(&toml_str).unwrap();
        assert!(
            unknown.is_empty(),
            "rendered toml with control chars must still parse clean, unknown: {unknown:?}"
        );
        assert_eq!(
            raw.alerts.as_ref().and_then(|a| a.webhook_url.clone()),
            Some(nasty.to_string()),
            "full round-trip preserves every byte"
        );
    }
}
