//! Byte-stable renderers from authoritative Rust state to NCL files (ADR-025 /
//! D9).
//!
//! Phase 1 covers `state.ncl` fully — the file consumed by the
//! `move_fsm_state` operation (O3). Other renderable paths
//! (`core.ncl`, `gate.ncl`, adr templates, backlog) return
//! [`RenderError::NotImplemented`] until their op consumers come online,
//! per the implement-on-demand discipline of the operations-layer plan.
//!
//! Round-trip invariant: `parse(render(state, p))` deserialises to a
//! `StateConfig` equal to the source (under `PartialEq` of the typed
//! representation — comments and import preludes are NOT preserved).
//!
//! Byte-stability invariant: `render(state, p)` produces identical bytes on
//! repeated invocations with the same input. Field ordering follows the
//! schema declaration order; string escaping is fully deterministic.

use std::path::Path;

use crate::types::{Dimension, DimensionState, StateConfig, Transition};

/// Errors emitted while rendering authoritative state to NCL bytes.
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
    /// The requested render path is not yet wired to a renderer. The path
    /// is preserved for diagnostic context.
    #[error("render not implemented for path '{path}' (Phase 1 covers state.ncl only)")]
    NotImplemented { path: String },
}

/// Render `state` to the NCL bytes appropriate for `path`.
///
/// Dispatch happens by the path's file name. Currently only `state.ncl`
/// is supported; everything else returns [`RenderError::NotImplemented`].
///
/// # Errors
///
/// - [`RenderError::NotImplemented`] when `path`'s file name is not
///   `state.ncl`.
pub fn render(state: &StateConfig, path: &Path) -> Result<String, RenderError> {
    let name = path
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or_default();
    match name {
        "state.ncl" => Ok(render_state_ncl(state)),
        _ => Err(RenderError::NotImplemented {
            path: path.display().to_string(),
        }),
    }
}

/// Byte-stable renderer for `state.ncl`. The output is a self-contained
/// Nickel record literal — no `import` prelude, no `d.make_*` shorthand —
/// that parses back into an equivalent [`StateConfig`].
pub fn render_state_ncl(state: &StateConfig) -> String {
    let mut out = String::with_capacity(8 * 1024);
    out.push_str("{\n  dimensions = [\n");
    for dim in &state.dimensions {
        push_dimension(&mut out, dim, /* base_indent */ 4);
    }
    out.push_str("  ],\n}\n");
    out
}

fn push_dimension(out: &mut String, dim: &Dimension, indent: usize) {
    let pad = " ".repeat(indent);
    let inner = " ".repeat(indent + 2);

    out.push_str(&pad);
    out.push_str("{\n");

    push_field_string(out, &inner, "id", &dim.id);
    push_field_string(out, &inner, "name", &dim.name);
    push_field_string(out, &inner, "description", &dim.description);
    push_field_string(out, &inner, "current_state", &dim.current_state);
    push_field_string(out, &inner, "desired_state", &dim.desired_state);
    push_field_enum(out, &inner, "horizon", &format!("{:?}", dim.horizon));

    push_field_states_array(out, &inner, &dim.states);
    push_field_transitions_array(out, &inner, &dim.transitions);
    push_field_coupled_with(out, &inner, &dim.coupled_with);

    out.push_str(&pad);
    out.push_str("},\n");
}

fn push_field_string(out: &mut String, pad: &str, name: &str, value: &str) {
    out.push_str(pad);
    out.push_str(name);
    out.push_str(" = ");
    push_escaped_string(out, value);
    out.push_str(",\n");
}

fn push_field_enum(out: &mut String, pad: &str, name: &str, value: &str) {
    out.push_str(pad);
    out.push_str(name);
    out.push_str(" = '");
    out.push_str(value);
    out.push_str(",\n");
}

fn push_field_states_array(out: &mut String, pad: &str, states: &[DimensionState]) {
    out.push_str(pad);
    out.push_str("states = [");
    if states.is_empty() {
        out.push_str("],\n");
        return;
    }
    out.push('\n');
    let entry_pad = format!("{pad}  ");
    let field_pad = format!("{pad}    ");
    for s in states {
        out.push_str(&entry_pad);
        out.push_str("{\n");
        push_field_string(out, &field_pad, "id", &s.id);
        push_field_string(out, &field_pad, "name", &s.name);
        push_field_string(out, &field_pad, "description", &s.description);
        push_field_enum(out, &field_pad, "tension", &format!("{:?}", s.tension));
        out.push_str(&entry_pad);
        out.push_str("},\n");
    }
    out.push_str(pad);
    out.push_str("],\n");
}

fn push_field_transitions_array(out: &mut String, pad: &str, transitions: &[Transition]) {
    out.push_str(pad);
    out.push_str("transitions = [");
    if transitions.is_empty() {
        out.push_str("],\n");
        return;
    }
    out.push('\n');
    let entry_pad = format!("{pad}  ");
    let field_pad = format!("{pad}    ");
    for t in transitions {
        out.push_str(&entry_pad);
        out.push_str("{\n");
        push_field_string(out, &field_pad, "from", &t.from);
        push_field_string(out, &field_pad, "to", &t.to);
        push_field_string(out, &field_pad, "condition", &t.condition);
        push_field_string(out, &field_pad, "catalyst", &t.catalyst);
        push_field_string(out, &field_pad, "blocker", &t.blocker);
        push_field_enum(out, &field_pad, "horizon", &format!("{:?}", t.horizon));
        out.push_str(&entry_pad);
        out.push_str("},\n");
    }
    out.push_str(pad);
    out.push_str("],\n");
}

fn push_field_coupled_with(out: &mut String, pad: &str, coupled: &[String]) {
    out.push_str(pad);
    out.push_str("coupled_with = [");
    if coupled.is_empty() {
        out.push_str("],\n");
        return;
    }
    for (i, c) in coupled.iter().enumerate() {
        if i > 0 {
            out.push_str(", ");
        }
        push_escaped_string(out, c);
    }
    out.push_str("],\n");
}

/// Escape a string into a Nickel-compatible double-quoted literal. Always
/// uses regular double-quote syntax (never `m%"..."%m`) for byte-stability;
/// every potentially-ambiguous byte is encoded as a `\xNN` or named escape.
fn push_escaped_string(out: &mut String, s: &str) {
    out.push('"');
    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"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{{{:04x}}}", c as u32)),
            // %{ is Nickel's interpolation opener inside m-strings; in
            // regular strings it would be `%{`. Escaping the `%` byte is
            // sufficient to neutralise any future ambiguity.
            '%' => out.push_str("\\u{0025}"),
            c => out.push(c),
        }
    }
    out.push('"');
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{DimensionState, Horizon, Transition};

    fn sample_state() -> StateConfig {
        use crate::types::TensionLevel;
        StateConfig {
            dimensions: vec![Dimension {
                id: "test-dim".to_string(),
                name: "Test Dimension".to_string(),
                description: "Sample for renderer".to_string(),
                current_state: "a".to_string(),
                desired_state: "b".to_string(),
                horizon: Horizon::Weeks,
                states: vec![DimensionState {
                    id: "a".to_string(),
                    name: "Initial".to_string(),
                    description: "initial".to_string(),
                    tension: TensionLevel::Low,
                }],
                transitions: vec![Transition {
                    from: "a".to_string(),
                    to: "b".to_string(),
                    condition: "ready".to_string(),
                    catalyst: "trigger".to_string(),
                    blocker: "none".to_string(),
                    horizon: Horizon::Weeks,
                }],
                coupled_with: vec!["other-dim".to_string()],
            }],
        }
    }

    #[test]
    fn deterministic_render() {
        let state = sample_state();
        let a = render_state_ncl(&state);
        let b = render_state_ncl(&state);
        assert_eq!(a, b, "render must be byte-stable");
    }

    #[test]
    fn unknown_path_returns_not_implemented() {
        let state = sample_state();
        let err = render(&state, Path::new(".ontology/core.ncl"))
            .expect_err("core.ncl is not yet supported");
        assert!(matches!(err, RenderError::NotImplemented { .. }));
    }

    #[test]
    fn escapes_newline_and_quote() {
        let mut s = String::new();
        push_escaped_string(&mut s, "line1\nline2 with \"quote\"");
        assert_eq!(s, "\"line1\\nline2 with \\\"quote\\\"\"");
    }

    #[test]
    fn escapes_percent_sign() {
        // The percent sign is escaped to prevent any future ambiguity with
        // Nickel's `%{...}` interpolation inside m-strings.
        let mut s = String::new();
        push_escaped_string(&mut s, "100%");
        assert_eq!(s, "\"100\\u{0025}\"");
    }
}
