//! Code generation — Rust ports of `packages/pen-codegen/src/`.
//! v1 ships the simplest of the 9 TS generators: `css-variables`,
//! which emits the design's variable table as CSS custom properties.
//! Future generators (React + Tailwind, HTML, Vue, Svelte, Flutter,
//! SwiftUI, Compose, React Native) live behind the same `Codegen`
//! trait so a single dispatcher (eventually a `op codegen <target>`
//! CLI) can fan out by target enum.
//!
//! All generators consume the canonical `jian_ops_schema::PenDocument`
//! and walk `PenNode` via `op_editor_core::PenNodeExt`. The spine here
//! holds the `Codegen` trait, the `CssVariables` generator, shared
//! node-accessor helpers, and the test suite; per-framework markup
//! generators live in the sibling `codegen_targets` module.

mod codegen_targets;

pub use crate::codegen_targets::{
    Compose, Flutter, Html, React, ReactNative, Svelte, SwiftUi, Vue,
};

use jian_ops_schema::node::PenNode;
use jian_ops_schema::style::PenFill;
use jian_ops_schema::variable::{VariableScalar, VariableValue};
use jian_ops_schema::PenDocument;
use op_editor_core::pen_node_ext::PenNodeExt;

/// One generator. Implementations are pure (read `PenDocument`,
/// return `String`); the CLI / panel layer owns file-write side
/// effects.
pub trait Codegen {
    fn target_label(&self) -> &'static str;
    fn generate(&self, doc: &PenDocument) -> String;
}

/// CSS custom properties generator. Walks `doc.variables` and emits
/// `:root { --name: value; }`. Color variables stay as their hex
/// string; number/bool/string variables stringify their scalar.
/// Themed values emit ONE entry per theme combination using a
/// `:root[data-axis="value"]` selector so the same CSS reflows
/// across theme switches with no JS.
pub struct CssVariables;

impl Codegen for CssVariables {
    fn target_label(&self) -> &'static str {
        "css-variables"
    }
    fn generate(&self, doc: &PenDocument) -> String {
        let mut out = String::new();
        out.push_str("/* Generated by OpenPencil — codegen::CssVariables */\n");
        let Some(variables) = doc.variables.as_ref() else {
            return out;
        };
        // Pass 1: scalar (theme-agnostic) variables go under :root.
        let mut root_scalars: Vec<(String, String)> = Vec::new();
        for (name, def) in variables {
            if let VariableValue::Scalar(s) = &def.value {
                root_scalars.push((name.clone(), scalar_to_css(s)));
            }
        }
        if !root_scalars.is_empty() {
            out.push_str(":root {\n");
            for (name, value) in &root_scalars {
                out.push_str(&format!("  --{}: {};\n", css_ident(name), value));
            }
            out.push_str("}\n");
        }
        // Pass 2: themed variables → one block per (axis, value) combo.
        // Group entries by their theme map signature so a "mode=dark"
        // entry from variable A and another from B share one block.
        use std::collections::BTreeMap;
        // Maps a theme-map signature (sorted axis/value pairs) to the
        // (variable-name, css-value) entries that share that signature.
        type ThemeSignature = Vec<(String, String)>;
        type ThemedBlock = Vec<(String, String)>;
        let mut themed: BTreeMap<ThemeSignature, ThemedBlock> = BTreeMap::new();
        for (name, def) in variables {
            if let VariableValue::Themed(entries) = &def.value {
                for e in entries {
                    let key: Vec<(String, String)> = e
                        .theme
                        .as_ref()
                        .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
                        .unwrap_or_default();
                    themed
                        .entry(key)
                        .or_default()
                        .push((name.clone(), scalar_to_css(&e.value)));
                }
            }
        }
        for (axes, decls) in &themed {
            if axes.is_empty() {
                out.push_str(":root {\n");
            } else {
                let sel = axes
                    .iter()
                    .map(|(k, v)| format!("[data-{}=\"{}\"]", k, v))
                    .collect::<Vec<_>>()
                    .join("");
                out.push_str(&format!(":root{} {{\n", sel));
            }
            for (name, value) in decls {
                out.push_str(&format!("  --{}: {};\n", css_ident(name), value));
            }
            out.push_str("}\n");
        }
        out
    }
}

// --- Shared node-accessor helpers (canonical-schema flavour) -------
//
// shell-core's flat `Node` exposed `bounds` / `fill` / `stroke` /
// `corner_radius` / `text` / `hidden` directly. The canonical
// `PenNode` is a 12-variant enum: these helpers give the markup
// generators the same flat view through `PenNodeExt`.

/// Top-level node forest the generators walk: the first page's
/// children when the document is paged, otherwise the bare
/// `children` fallback.
pub(crate) fn root_nodes(doc: &PenDocument) -> &[PenNode] {
    match doc.pages.as_ref().and_then(|p| p.first()) {
        Some(page) => &page.children,
        None => &doc.children,
    }
}

/// True when the node is explicitly hidden (`visible == false`).
/// A missing `visible` flag means shown.
pub(crate) fn node_hidden(node: &PenNode) -> bool {
    node.base().visible == Some(false)
}

/// Document-space origin of the node (`base.x` / `base.y`, 0 default).
pub(crate) fn node_origin(node: &PenNode) -> (f64, f64) {
    let base = node.base();
    (base.x.unwrap_or(0.0), base.y.unwrap_or(0.0))
}

/// Literal pixel size of the node. Keyword / expression sizing is not
/// a concrete number, so it reads as 0 — same degrade the TS
/// generators apply for `fit_content` / `$var` widths.
pub(crate) fn node_size(node: &PenNode) -> (f64, f64) {
    match node {
        PenNode::Line(n) => {
            // A line has no width/height field — derive its box from
            // the (x,y)→(x2,y2) span so absolute layout still works.
            let base = node.base();
            let x1 = base.x.unwrap_or(0.0);
            let y1 = base.y.unwrap_or(0.0);
            let x2 = n.x2.unwrap_or(x1);
            let y2 = n.y2.unwrap_or(y1);
            ((x2 - x1).abs(), (y2 - y1).abs())
        }
        _ => (
            node.width_px().unwrap_or(0.0),
            node.height_px().unwrap_or(0.0),
        ),
    }
}

/// Node rotation in radians (`base.rotation`, 0 default). The
/// canonical schema stores rotation in degrees; the editor / TS
/// renderer treat that field as degrees too, so emit it verbatim and
/// let the per-target helper pick `deg` vs `rad`.
pub(crate) fn node_rotation_deg(node: &PenNode) -> f64 {
    node.base().rotation.unwrap_or(0.0)
}

/// Corner radius in document px. Uniform `CornerRadius` reads as-is;
/// per-corner radii collapse to their first value (CSS shorthand is
/// a follow-up). Variants without a corner-radius field read 0.
pub(crate) fn node_corner_radius(node: &PenNode) -> f64 {
    use jian_ops_schema::node::CornerRadius;
    let uniform = |c: &Option<CornerRadius>| match c {
        Some(CornerRadius::Uniform(r)) => *r,
        Some(CornerRadius::PerCorner(arr)) => arr.first().copied().unwrap_or(0.0),
        None => 0.0,
    };
    match node {
        PenNode::Frame(n) => uniform(&n.container.corner_radius),
        PenNode::Group(n) => uniform(&n.container.corner_radius),
        PenNode::Rectangle(n) => uniform(&n.container.corner_radius),
        PenNode::Ellipse(n) => n.corner_radius.unwrap_or(0.0),
        PenNode::Polygon(n) => n.corner_radius.unwrap_or(0.0),
        _ => 0.0,
    }
}

/// Children of the node (empty slice for leaf variants).
pub(crate) fn node_children(node: &PenNode) -> &[PenNode] {
    node.children().map(|v| v.as_slice()).unwrap_or(&[])
}

/// True when the node renders as text (`Text` variant).
pub(crate) fn node_is_text(node: &PenNode) -> bool {
    matches!(node, PenNode::Text(_))
}

/// True when the node renders as an ellipse.
pub(crate) fn node_is_ellipse(node: &PenNode) -> bool {
    matches!(node, PenNode::Ellipse(_))
}

/// Plain-text body of a node. `Text` variants flatten their
/// `TextContent` (joining styled segments); every other variant has
/// no inline body.
pub(crate) fn node_text(node: &PenNode) -> Option<String> {
    use jian_ops_schema::node::TextContent;
    match node {
        PenNode::Text(n) => Some(match &n.content {
            TextContent::Plain(s) => s.clone(),
            TextContent::Styled(segs) => segs.iter().map(|s| s.text.as_str()).collect::<String>(),
        }),
        PenNode::TextInput(n) => n.value.clone().or_else(|| n.placeholder.clone()),
        _ => None,
    }
}

/// First solid-fill color of a node as a `#hex` / `rgba()` string,
/// when the node carries one. Gradient / image fills are not a flat
/// color — they read as `None` (same degrade as the TS generators).
pub(crate) fn node_fill_css(node: &PenNode) -> Option<String> {
    let fills = node_fills(node)?;
    first_solid_color(fills)
}

/// Borrow the node's fill list, when its variant carries fills.
fn node_fills(node: &PenNode) -> Option<&Vec<PenFill>> {
    match node {
        PenNode::Frame(n) => n.container.fill.as_ref(),
        PenNode::Group(n) => n.container.fill.as_ref(),
        PenNode::Rectangle(n) => n.container.fill.as_ref(),
        PenNode::Ellipse(n) => n.fill.as_ref(),
        PenNode::Polygon(n) => n.fill.as_ref(),
        PenNode::Path(n) => n.fill.as_ref(),
        PenNode::Text(n) => n.fill.as_ref(),
        PenNode::TextInput(n) => n.fill.as_ref(),
        PenNode::IconFont(n) => n.fill.as_ref(),
        PenNode::Line(_) | PenNode::Image(_) | PenNode::Ref(_) => None,
    }
}

/// First `Solid` fill's color string in a fill list.
fn first_solid_color(fills: &[PenFill]) -> Option<String> {
    fills.iter().find_map(|f| match f {
        PenFill::Solid(body) => Some(body.color.clone()),
        _ => None,
    })
}

/// Stroke description of a node: `(color, width)`. Present only when
/// the variant carries a `PenStroke` with a solid fill and a uniform
/// thickness. Per-side thickness collapses to its first value.
pub(crate) fn node_stroke_css(node: &PenNode) -> Option<(String, f32)> {
    use jian_ops_schema::style::StrokeThickness;
    let stroke = match node {
        PenNode::Frame(n) => n.container.stroke.as_ref(),
        PenNode::Group(n) => n.container.stroke.as_ref(),
        PenNode::Rectangle(n) => n.container.stroke.as_ref(),
        PenNode::Ellipse(n) => n.stroke.as_ref(),
        PenNode::Polygon(n) => n.stroke.as_ref(),
        PenNode::Path(n) => n.stroke.as_ref(),
        PenNode::Line(n) => n.stroke.as_ref(),
        PenNode::TextInput(n) => n.stroke.as_ref(),
        PenNode::IconFont(n) => n.stroke.as_ref(),
        PenNode::Text(_) | PenNode::Image(_) | PenNode::Ref(_) => None,
    }?;
    let width = match &stroke.thickness {
        StrokeThickness::Uniform(w) => *w,
        StrokeThickness::PerSide(arr) => arr.first().copied().unwrap_or(0.0),
        StrokeThickness::Sided(s) => s.top.or(s.right).or(s.bottom).or(s.left).unwrap_or(0.0),
    };
    let color = stroke
        .fill
        .as_ref()
        .and_then(|fills| first_solid_color(fills))
        .unwrap_or_else(|| "#000000".to_string());
    Some((color, width))
}

// --- CSS scalar / colour helpers -----------------------------------

/// Parse a `#rgb` / `#rrggbb` / `#rrggbbaa` / `rgba(...)` colour
/// string into normalized `(r, g, b, a)` floats in `0.0..=1.0`.
/// Returns `None` for anything not understood — callers fall back to
/// emitting the raw string verbatim, which is valid CSS for named
/// colours or `var(--ref)`.
pub(crate) fn parse_color(s: &str) -> Option<(f32, f32, f32, f32)> {
    let t = s.trim();
    if let Some(hex) = t.strip_prefix('#') {
        let h = hex.as_bytes();
        let nyb = |c: u8| -> Option<u8> {
            match c {
                b'0'..=b'9' => Some(c - b'0'),
                b'a'..=b'f' => Some(c - b'a' + 10),
                b'A'..=b'F' => Some(c - b'A' + 10),
                _ => None,
            }
        };
        let pair =
            |hi: u8, lo: u8| -> Option<f32> { Some((nyb(hi)? * 16 + nyb(lo)?) as f32 / 255.0) };
        return match h.len() {
            3 => Some((pair(h[0], h[0])?, pair(h[1], h[1])?, pair(h[2], h[2])?, 1.0)),
            6 => Some((pair(h[0], h[1])?, pair(h[2], h[3])?, pair(h[4], h[5])?, 1.0)),
            8 => Some((
                pair(h[0], h[1])?,
                pair(h[2], h[3])?,
                pair(h[4], h[5])?,
                pair(h[6], h[7])?,
            )),
            _ => None,
        };
    }
    None
}

/// Normalize a colour string for emission as a CSS value. Hex /
/// rgba inputs round-trip through `parse_color`; anything else
/// (named colour, `var(--ref)`) passes through verbatim.
pub(crate) fn color_to_css(s: &str) -> String {
    match parse_color(s) {
        Some((r, g, b, a)) => {
            let to_u8 = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
            if a >= 0.999 {
                format!("rgb({},{},{})", to_u8(r), to_u8(g), to_u8(b))
            } else {
                format!("rgba({},{},{},{:.3})", to_u8(r), to_u8(g), to_u8(b), a)
            }
        }
        None => s.to_string(),
    }
}

pub(crate) fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn scalar_to_css(s: &VariableScalar) -> String {
    match s {
        VariableScalar::Bool(b) => b.to_string(),
        VariableScalar::Num(n) => fmt_num(*n),
        VariableScalar::Str(s) => s.clone(),
    }
}

/// Format an `f64` the way the TS generators print numbers — drop a
/// trailing `.0` so `12.0` renders as `12`.
pub(crate) fn fmt_num(n: f64) -> String {
    if n.fract() == 0.0 && n.is_finite() {
        format!("{}", n as i64)
    } else {
        format!("{}", n)
    }
}

fn css_ident(name: &str) -> String {
    name.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' {
                c
            } else {
                '-'
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use jian_ops_schema::node::{EllipseNode, PenNodeBase, RectangleNode, TextContent, TextNode};
    use jian_ops_schema::page::PenPage;
    use jian_ops_schema::sizing::SizingBehavior;
    use jian_ops_schema::style::{PenFill, SolidFillBody};
    use jian_ops_schema::variable::{
        ThemedValue, VariableDefinition, VariableKind, VariableScalar, VariableValue,
    };
    use std::collections::BTreeMap;

    /// Bare empty document — no variables, no pages, no children.
    fn empty_doc() -> PenDocument {
        PenDocument {
            version: "0.8.0".into(),
            name: None,
            themes: None,
            variables: None,
            pages: None,
            children: Vec::new(),
            format_version: None,
            id: None,
            app: None,
            routes: None,
            state: None,
            lifecycle: None,
            logic_modules: None,
            design_md: None,
        }
    }

    /// Replace the document's node forest with a single page holding
    /// `nodes`, mirroring the old `page.children` test helper.
    fn doc_with_nodes(nodes: Vec<PenNode>) -> PenDocument {
        let mut doc = empty_doc();
        doc.pages = Some(vec![PenPage {
            id: "p1".into(),
            name: "Page 1".into(),
            children: nodes,
            state: None,
            lifecycle: None,
        }]);
        doc
    }

    fn axis(name: &str, value: &str) -> BTreeMap<String, String> {
        let mut m = BTreeMap::new();
        m.insert(name.to_string(), value.to_string());
        m
    }

    fn solid(color: &str) -> Vec<PenFill> {
        vec![PenFill::Solid(SolidFillBody {
            color: color.into(),
            explain: None,
            opacity: None,
            blend_mode: None,
        })]
    }

    /// A rectangle at `(x,y)` sized `w×h` with an optional fill.
    fn rect(id: &str, x: f64, y: f64, w: f64, h: f64, fill: Option<&str>) -> PenNode {
        let container = jian_ops_schema::node::ContainerProps {
            width: Some(SizingBehavior::Number(w)),
            height: Some(SizingBehavior::Number(h)),
            fill: fill.map(solid),
            ..Default::default()
        };
        PenNode::Rectangle(RectangleNode {
            base: PenNodeBase {
                id: id.into(),
                x: Some(x),
                y: Some(y),
                ..Default::default()
            },
            container,
            children: None,
            state: None,
            bindings: None,
            events: None,
            lifecycle: None,
            semantics: None,
            gestures: None,
            route: None,
        })
    }

    fn ellipse(id: &str, x: f64, y: f64, w: f64, h: f64) -> PenNode {
        PenNode::Ellipse(EllipseNode {
            base: PenNodeBase {
                id: id.into(),
                x: Some(x),
                y: Some(y),
                ..Default::default()
            },
            width: Some(SizingBehavior::Number(w)),
            height: Some(SizingBehavior::Number(h)),
            corner_radius: None,
            inner_radius: None,
            start_angle: None,
            sweep_angle: None,
            fill: None,
            stroke: None,
            effects: None,
            state: None,
            bindings: None,
            events: None,
            lifecycle: None,
            semantics: None,
            gestures: None,
            route: None,
        })
    }

    fn text(id: &str, x: f64, y: f64, w: f64, h: f64, body: &str) -> PenNode {
        PenNode::Text(TextNode {
            base: PenNodeBase {
                id: id.into(),
                x: Some(x),
                y: Some(y),
                ..Default::default()
            },
            width: Some(SizingBehavior::Number(w)),
            height: Some(SizingBehavior::Number(h)),
            content: TextContent::Plain(body.into()),
            font_family: None,
            font_size: None,
            font_weight: None,
            font_style: None,
            letter_spacing: None,
            line_height: None,
            text_align: None,
            text_align_vertical: None,
            text_growth: None,
            underline: None,
            strikethrough: None,
            fill: None,
            effects: None,
            state: None,
            bindings: None,
            events: None,
            lifecycle: None,
            semantics: None,
            gestures: None,
            route: None,
        })
    }

    fn color_def(value: &str) -> VariableDefinition {
        VariableDefinition {
            kind: VariableKind::Color,
            value: VariableValue::Scalar(VariableScalar::Str(value.into())),
        }
    }

    #[test]
    fn css_variables_emits_root_scalars() {
        let mut doc = empty_doc();
        let mut vars = BTreeMap::new();
        vars.insert("primary".to_string(), color_def("#0066ff"));
        vars.insert(
            "spacing".to_string(),
            VariableDefinition {
                kind: VariableKind::Number,
                value: VariableValue::Scalar(VariableScalar::Num(12.0)),
            },
        );
        doc.variables = Some(vars);
        let css = CssVariables.generate(&doc);
        assert!(css.contains(":root {"));
        assert!(css.contains("--primary: #0066ff;"));
        assert!(css.contains("--spacing: 12;"));
    }

    #[test]
    fn css_variables_emits_per_theme_block() {
        let mut doc = empty_doc();
        let mut vars = BTreeMap::new();
        vars.insert(
            "bg".to_string(),
            VariableDefinition {
                kind: VariableKind::Color,
                value: VariableValue::Themed(vec![
                    ThemedValue {
                        value: VariableScalar::Str("#ffffff".into()),
                        theme: Some(axis("mode", "light")),
                    },
                    ThemedValue {
                        value: VariableScalar::Str("#000000".into()),
                        theme: Some(axis("mode", "dark")),
                    },
                ]),
            },
        );
        doc.variables = Some(vars);
        let css = CssVariables.generate(&doc);
        assert!(css.contains(":root[data-mode=\"light\"]"));
        assert!(css.contains(":root[data-mode=\"dark\"]"));
        assert!(css.contains("--bg: #ffffff;"));
        assert!(css.contains("--bg: #000000;"));
    }

    #[test]
    fn css_variables_sanitises_non_ident_chars() {
        let mut doc = empty_doc();
        let mut vars = BTreeMap::new();
        vars.insert("primary.color".to_string(), color_def("#f00"));
        doc.variables = Some(vars);
        let css = CssVariables.generate(&doc);
        // Dot replaced with dash so the result is a valid CSS ident.
        assert!(css.contains("--primary-color: #f00;"));
    }

    #[test]
    fn html_emits_doctype_and_body() {
        let doc = empty_doc();
        let html = Html.generate(&doc);
        assert!(html.contains("<!DOCTYPE html>"));
        assert!(html.contains("<body>"));
        assert!(html.contains("</body>"));
        assert!(html.contains("OpenPencil"));
    }

    #[test]
    fn html_emits_div_per_node_with_position_and_fill() {
        let doc = doc_with_nodes(vec![rect("n10", 20.0, 30.0, 100.0, 50.0, Some("#ff0000"))]);
        let html = Html.generate(&doc);
        assert!(html.contains("left:20px"));
        assert!(html.contains("top:30px"));
        assert!(html.contains("width:100px"));
        assert!(html.contains("rgb(255,0,0)"));
    }

    #[test]
    fn html_emits_span_for_text_and_escapes_body() {
        let doc = doc_with_nodes(vec![text("n10", 0.0, 0.0, 100.0, 24.0, "Hello & <world>")]);
        let html = Html.generate(&doc);
        assert!(html.contains("<span"));
        assert!(html.contains("Hello &amp; &lt;world&gt;"));
    }

    #[test]
    fn vue_emits_template_script_style_blocks() {
        let doc = empty_doc();
        let s = Vue.generate(&doc);
        assert!(s.contains("<template>"));
        assert!(s.contains("</template>"));
        assert!(s.contains("<script setup"));
        assert!(s.contains("<style scoped>"));
    }

    #[test]
    fn svelte_emits_script_then_markup_then_style() {
        let doc = empty_doc();
        let s = Svelte.generate(&doc);
        let script_pos = s.find("<script").unwrap();
        let style_pos = s.find("<style>").unwrap();
        // Script before style — Svelte SFC convention.
        assert!(script_pos < style_pos);
    }

    #[test]
    fn vue_includes_variable_css_in_style_block() {
        let mut doc = empty_doc();
        let mut vars = BTreeMap::new();
        vars.insert("primary".to_string(), color_def("#abc"));
        doc.variables = Some(vars);
        let s = Vue.generate(&doc);
        assert!(s.contains("--primary: #abc;"));
    }

    #[test]
    fn react_emits_functional_component_with_jsx_fragment() {
        let doc = empty_doc();
        let s = React.generate(&doc);
        assert!(s.contains("import React from 'react';"));
        assert!(s.contains("export default function Page()"));
        assert!(s.contains("<>"));
        assert!(s.contains("</>"));
    }

    #[test]
    fn flutter_emits_stack_with_positioned_children() {
        let doc = doc_with_nodes(vec![rect("n10", 5.0, 10.0, 100.0, 50.0, Some("#ff0000"))]);
        let s = Flutter.generate(&doc);
        assert!(s.contains("import 'package:flutter/material.dart';"));
        assert!(s.contains("Stack(children:"));
        assert!(s.contains("Positioned(left: 5, top: 10"));
        assert!(s.contains("Container(width: 100, height: 50"));
        assert!(s.contains("Color.fromARGB(255,255,0,0)"));
    }

    #[test]
    fn swiftui_emits_zstack_with_positioned_views() {
        let doc = doc_with_nodes(vec![rect("n10", 0.0, 0.0, 100.0, 50.0, None)]);
        let s = SwiftUi.generate(&doc);
        assert!(s.contains("import SwiftUI"));
        assert!(s.contains("ZStack {"));
        assert!(s.contains("Rectangle()"));
        assert!(s.contains(".frame(width: 100, height: 50)"));
    }

    #[test]
    fn swiftui_emits_ellipse_for_ellipse_kind() {
        let doc = doc_with_nodes(vec![ellipse("n10", 0.0, 0.0, 20.0, 20.0)]);
        let s = SwiftUi.generate(&doc);
        assert!(s.contains("Ellipse()"));
    }

    #[test]
    fn compose_emits_composable_box_with_offset_size() {
        let doc = doc_with_nodes(vec![rect("n10", 5.0, 10.0, 100.0, 50.0, Some("#ff0000"))]);
        let s = Compose.generate(&doc);
        assert!(s.contains("@Composable"));
        assert!(s.contains("Modifier.offset(x = 5.dp, y = 10.dp)"));
        assert!(s.contains("size(width = 100.dp, height = 50.dp)"));
        assert!(s.contains("Color(255, 0, 0, 255)"));
    }

    #[test]
    fn react_native_emits_view_tree_with_absolute_positions() {
        let doc = doc_with_nodes(vec![rect("n10", 0.0, 0.0, 80.0, 40.0, None)]);
        let s = ReactNative.generate(&doc);
        assert!(s.contains("import { View, Text } from 'react-native';"));
        assert!(s.contains("export default function Page()"));
        assert!(s.contains("<View style={{"));
        assert!(s.contains("position: 'absolute'"));
        assert!(s.contains("width: 80, height: 40"));
    }

    #[test]
    fn html_skips_hidden_nodes() {
        let mut hidden = rect("n10", 0.0, 0.0, 10.0, 10.0, None);
        hidden.base_mut().visible = Some(false);
        hidden.base_mut().name = Some("hidden".into());
        let doc = doc_with_nodes(vec![hidden]);
        let html = Html.generate(&doc);
        assert!(!html.contains("hidden"));
    }

    #[test]
    fn css_variables_empty_doc_emits_header_only() {
        let doc = empty_doc();
        let css = CssVariables.generate(&doc);
        assert!(css.contains("Generated by OpenPencil"));
        assert!(!css.contains(":root"));
    }

    #[test]
    fn falls_back_to_bare_children_when_unpaged() {
        let mut doc = empty_doc();
        doc.children = vec![rect("n10", 1.0, 2.0, 30.0, 40.0, None)];
        let html = Html.generate(&doc);
        assert!(html.contains("left:1px"));
        assert!(html.contains("width:30px"));
    }
}
