use super::constants::*;
use super::templates::{self, actor_man as tmpl_actor_man, defs_svg, esc};
use crate::backends::measure;
/// Sequence diagram renderer — faithful port of Mermaid sequenceRenderer.ts
///
/// Algorithm (same as Mermaid):
///   1. Collect participants (implicit + explicit)
///   2. Measure actor box widths based on message widths (getMaxMessageWidthPerActor)
///   3. Compute actor x positions with margins (calculateActorMargins)
///   4. Vertical layout: walk items, bump verticalPos for each event
///   5. Emit SVG actors (top + bottom), lifelines, messages, control structures
use crate::diagrams::sequence::parser::{
    LineType, NotePlacement, ParticipantKind, SeqItem, SequenceDiagram,
};
use crate::theme::Theme;

// ─── Mermaid default sequence config ───────────────────────────────────────
// All constants are imported from super::constants via `use super::constants::*`.

fn actor_visual_height(kind: &ParticipantKind) -> f64 {
    match kind {
        ParticipantKind::Actor => ACTOR_MAN_HEIGHT,
        _ => ACTOR_HEIGHT,
    }
}

// ─── Internal actor model ───────────────────────────────────────────────────
#[derive(Debug, Clone)]
struct Actor {
    name: String,  // ID used for message lookups
    alias: String, // display label (same as name when no alias declared)
    kind: ParticipantKind,
    x: f64,
    y: f64,
    width: f64,
    height: f64,
    margin: f64,
    start_y: f64, // top lifeline start
    stop_y: f64,  // bottom lifeline end
}

impl Actor {
    fn cx(&self) -> f64 {
        self.x + self.width / 2.0
    }
}

// ─── Activation box ─────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
struct Activation {
    actor: String,
    start_x: f64,
    start_y: f64,
    stop_x: f64,
    stop_y: f64,
}

// ─── Note model ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
struct RenderedNote {
    start_x: f64,
    start_y: f64,
    width: f64,
    height: f64,
    text: String,
}

// ─── Message model ──────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
struct MsgModel {
    from: String,
    to: String,
    start_x: f64,
    stop_x: f64,
    from_bounds: f64,
    to_bounds: f64,
    start_y: f64,
    line_start_y: f64,
    text: String,
    line_type: LineType,
    #[allow(dead_code)]
    activate: i32,
    seq_idx: usize,
    show_seq: bool,
    #[allow(dead_code)]
    height: f64,
}

// ─── Loop/Alt/Par model ─────────────────────────────────────────────────────
#[derive(Debug, Clone)]
enum ControlKind {
    Loop,
    Alt,
    Opt,
    Par,
    Critical,
    Break,
}

#[derive(Debug, Clone)]
struct ControlSection {
    y: f64,
    label: String,
}

#[derive(Debug, Clone)]
struct ControlModel {
    kind: ControlKind,
    label: String,
    start_x: f64,
    stop_x: f64,
    start_y: f64,
    stop_y: f64,
    sections: Vec<ControlSection>, // for alt/par dividers
}

// ─── Bounds tracker ─────────────────────────────────────────────────────────
#[derive(Debug, Default)]
struct Bounds {
    start_x: f64,
    stop_x: f64,
    start_y: f64,
    stop_y: f64,
    vertical_pos: f64,
    loop_stack: Vec<LoopFrame>,
    activations: Vec<Activation>,
}

#[derive(Debug, Clone)]
struct LoopFrame {
    label: String,
    kind: ControlKind,
    from: f64,
    to: f64,
    start_y: f64,
    stop_y: f64, // tracks max stopy (with boxMargin) from inserts inside loop
    sections: Vec<ControlSection>,
    #[allow(dead_code)]
    is_alt: bool, // alt has vertical sections
}

impl Bounds {
    fn bump(&mut self, h: f64) {
        self.vertical_pos += h;
        if self.vertical_pos > self.stop_y {
            self.stop_y = self.vertical_pos;
        }
    }

    fn insert(&mut self, x1: f64, y1: f64, x2: f64, y2: f64) {
        let lx = x1.min(x2);
        let rx = x1.max(x2);
        let ty = y1.min(y2);
        let by = y1.max(y2);
        if lx < self.start_x {
            self.start_x = lx;
        }
        if rx > self.stop_x {
            self.stop_x = rx;
        }
        if ty < self.start_y {
            self.start_y = ty;
        }
        if by > self.stop_y {
            self.stop_y = by;
        }
        // Mermaid updateBounds: propagate stopy+n*boxMargin to each loop frame
        let n_frames = self.loop_stack.len();
        for (i, frame) in self.loop_stack.iter_mut().enumerate() {
            let depth = (n_frames - i) as f64; // 1-based nesting depth
            let padded = by + depth * BOX_MARGIN;
            if padded > frame.stop_y {
                frame.stop_y = padded;
            }
        }
    }
}

// ─── SVG helpers ────────────────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
fn actor_rect_svg(
    x: f64,
    y: f64,
    w: f64,
    h: f64,
    name: &str,
    cls: &str,
    pf: &str,
    pb: &str,
) -> String {
    templates::actor_rect(x, y, w, h, &esc(name), cls, pf, pb)
}

/// Render an actor-man (stick figure) at actor box position (top or bottom)
#[allow(clippy::too_many_arguments)]
/// cx = center x, box_y = top of the actor box area (y=0 for top, bottom_actor_y for bottom)
fn actor_man_svg(
    cx: f64,
    box_y: f64,
    name: &str,
    cls: &str,
    idx: usize,
    pf: &str,
    pb: &str,
    tc: &str,
    ff: &str,
) -> String {
    let cy = box_y + 10.0; // circle center y
    let r = 15.0_f64;
    let ts = cy + r; // torso start y
    let te = cy + r + 20.0; // torso end y
    let al = cx - 18.0; // arm left x
    let ar = cx + 18.0; // arm right x
    let ay = cy + r + 8.0; // arm y
    let ls = cy + r + 20.0; // leg start y (= torso end)
    let le = cy + r + 35.0; // leg end y
    let ll = cx - 16.0; // leg left x
    let rl = cx + 16.0; // leg right x
    let ty = box_y + ACTOR_MAN_HEIGHT - 12.5; // text y (ref uses -12.5 for 2px gap below legs)
    let esc_name = esc(name);
    tmpl_actor_man(
        cls,
        &esc_name,
        idx,
        cx,
        cy,
        r,
        ts,
        te,
        al,
        ar,
        ay,
        ll,
        rl,
        ls,
        le,
        ty,
        ACTOR_WIDTH,
        ACTOR_MAN_HEIGHT,
        FONT_SIZE as u32,
        pf,
        pb,
        tc,
        ff,
    )
}

fn actor_text_svg(cx: f64, cy: f64, name: &str, tc: &str, ff: &str) -> String {
    templates::actor_text(cx, cy, FONT_SIZE, &esc(name), tc, ff)
}

// ─── Main render entry point ─────────────────────────────────────────────────

pub fn render(diag: &SequenceDiagram, theme: Theme, _use_foreign_object: bool) -> String {
    let diagram_id = DIAGRAM_ID;
    let vars = theme.resolve();

    // ── Step 1: collect participants ──────────────────────────────────────────
    let mut actor_order: Vec<String> = Vec::new();
    let mut actor_map: std::collections::HashMap<String, Actor> = std::collections::HashMap::new();

    // Collect box groups: (label, color, actor_names)
    struct BoxGroup {
        label: String,
        color: Option<String>,
        actors: Vec<String>,
    }
    let mut box_groups: Vec<BoxGroup> = Vec::new();
    {
        let mut in_box: Option<(String, Option<String>)> = None;
        for item in &diag.items {
            match item {
                SeqItem::BoxStart { label, color } => {
                    in_box = Some((label.clone(), color.clone()));
                    box_groups.push(BoxGroup {
                        label: label.clone(),
                        color: color.clone(),
                        actors: Vec::new(),
                    });
                }
                SeqItem::BlockEnd => {
                    in_box = None;
                }
                SeqItem::Participant(p) if in_box.is_some() => {
                    if let Some(bg) = box_groups.last_mut() {
                        bg.actors.push(p.name.clone());
                    }
                }
                SeqItem::Participant(_) => {}
                _ => {}
            }
            let _ = in_box.as_ref(); // suppress unused warning
        }
    }

    // First pass: explicit participants
    for item in &diag.items {
        if let SeqItem::Participant(p) = item {
            if !actor_map.contains_key(&p.name) {
                actor_order.push(p.name.clone());
                // Layout height = ACTOR_HEIGHT=65 for all (Mermaid uses conf.height for spacing)
                // Visual height is stored for rendering the actor-man figure
                let visual_h = actor_visual_height(&p.kind);
                actor_map.insert(
                    p.name.clone(),
                    Actor {
                        name: p.name.clone(),
                        alias: p.alias.clone(),
                        kind: p.kind.clone(),
                        x: 0.0,
                        y: 0.0,
                        width: ACTOR_WIDTH,
                        height: visual_h, // used for lifeline start and visual rendering
                        margin: ACTOR_MARGIN,
                        start_y: 0.0,
                        stop_y: 0.0,
                    },
                );
            }
        }
    }

    // Second pass: implicit participants from messages/notes
    for item in &diag.items {
        match item {
            SeqItem::Message(m) => {
                for name in &[&m.from, &m.to] {
                    if !actor_map.contains_key(*name) {
                        actor_order.push((*name).clone());
                        actor_map.insert(
                            (*name).clone(),
                            Actor {
                                name: (*name).clone(),
                                alias: (*name).clone(),
                                kind: ParticipantKind::Participant,
                                x: 0.0,
                                y: 0.0,
                                width: ACTOR_WIDTH,
                                height: ACTOR_HEIGHT,
                                margin: ACTOR_MARGIN,
                                start_y: 0.0,
                                stop_y: 0.0,
                            },
                        );
                    }
                }
            }
            SeqItem::Note(n) => {
                for name in &n.actors {
                    if !actor_map.contains_key(name) {
                        actor_order.push(name.clone());
                        actor_map.insert(
                            name.clone(),
                            Actor {
                                name: name.clone(),
                                alias: name.clone(),
                                kind: ParticipantKind::Participant,
                                x: 0.0,
                                y: 0.0,
                                width: ACTOR_WIDTH,
                                height: ACTOR_HEIGHT,
                                margin: ACTOR_MARGIN,
                                start_y: 0.0,
                                stop_y: 0.0,
                            },
                        );
                    }
                }
            }
            _ => {}
        }
    }

    // ── Step 2: getMaxMessageWidthPerActor ────────────────────────────────────
    // For each actor, what is the max width of messages that originate/arrive?
    let mut max_msg_width_per_actor: std::collections::HashMap<String, f64> =
        std::collections::HashMap::new();

    let n_actors = actor_order.len();
    for item in diag.items.iter() {
        match item {
            SeqItem::Message(m) => {
                let from_idx = actor_order.iter().position(|n| n == &m.from);
                let to_idx = actor_order.iter().position(|n| n == &m.to);
                if let (Some(fi), Some(ti)) = (from_idx, to_idx) {
                    let (w, _) = measure(&m.text, FONT_SIZE);
                    let msg_w = w + 2.0 * WRAP_PADDING;

                    if fi == ti {
                        // self-message
                        let e = max_msg_width_per_actor.entry(m.from.clone()).or_insert(0.0);
                        *e = e.max(msg_w / 2.0);
                        let e2 = max_msg_width_per_actor.entry(m.to.clone()).or_insert(0.0);
                        *e2 = e2.max(msg_w / 2.0);
                    } else if fi < ti {
                        // arrow goes right: from_actor defines margin
                        let e = max_msg_width_per_actor.entry(m.from.clone()).or_insert(0.0);
                        *e = e.max(msg_w);
                    } else {
                        // arrow goes left: to_actor defines margin
                        let e = max_msg_width_per_actor.entry(m.to.clone()).or_insert(0.0);
                        *e = e.max(msg_w);
                    }
                }
            }
            SeqItem::Note(n) => {
                let (w, _) = measure(&n.text, FONT_SIZE);
                let note_w = w + 2.0 * NOTE_MARGIN;
                let actor_name = n.actors.first().map(|s| s.as_str()).unwrap_or("");
                match n.placement {
                    NotePlacement::RightOf => {
                        let e = max_msg_width_per_actor
                            .entry(actor_name.to_string())
                            .or_insert(0.0);
                        *e = e.max(note_w);
                    }
                    NotePlacement::LeftOf => {
                        // previous actor gets the margin
                        if let Some(idx) = actor_order.iter().position(|n| n == actor_name) {
                            if idx > 0 {
                                let prev = actor_order[idx - 1].clone();
                                let e = max_msg_width_per_actor.entry(prev).or_insert(0.0);
                                *e = e.max(note_w);
                            }
                        }
                    }
                    NotePlacement::Over => {
                        // split across prev and current
                        if let Some(idx) = actor_order.iter().position(|n| n == actor_name) {
                            if idx > 0 {
                                let prev = actor_order[idx - 1].clone();
                                let e = max_msg_width_per_actor.entry(prev).or_insert(0.0);
                                *e = e.max(note_w / 2.0);
                            }
                            if idx + 1 < n_actors {
                                let e = max_msg_width_per_actor
                                    .entry(actor_name.to_string())
                                    .or_insert(0.0);
                                *e = e.max(note_w / 2.0);
                            }
                        }
                    }
                }
            }
            _ => {}
        }
    }

    // ── Step 3: calculateActorMargins → assign x positions ───────────────────
    let mut prev_x = 0.0;
    for (idx, name) in actor_order.iter().enumerate() {
        // determine margin for this actor (read-only borrows first)
        let msg_w = max_msg_width_per_actor.get(name).copied().unwrap_or(0.0);
        let actor_width = actor_map.get(name).map(|a| a.width).unwrap_or(ACTOR_WIDTH);
        let next_actor_width = if idx + 1 < n_actors {
            actor_map
                .get(&actor_order[idx + 1])
                .map(|a| a.width)
                .unwrap_or(ACTOR_WIDTH)
        } else {
            ACTOR_WIDTH
        };
        let margin = if idx + 1 < n_actors {
            (msg_w + ACTOR_MARGIN - actor_width / 2.0 - next_actor_width / 2.0).max(ACTOR_MARGIN)
        } else {
            ACTOR_MARGIN
        };

        // Round margin to integer to match Mermaid's browser-integer text measurements.
        // Browser getBBox() returns values that happen to produce integer margins for
        // these corpus diagrams; our scaled ab_glyph measurements can be fractional.
        let margin_rounded = margin.round();

        // now mutate
        let actor = actor_map.get_mut(name).unwrap();
        actor.x = prev_x;
        // Mermaid: if hasBoxes, bumpVerticalPos(boxMargin) + bumpVerticalPos(textMaxHeight)
        // before drawing actors, so all actors start lower to leave space for box labels.
        let actor_y = if !box_groups.is_empty() {
            let max_label_h = box_groups
                .iter()
                .filter(|bg| !bg.label.is_empty())
                .map(|_| FONT_SIZE)
                .next()
                .unwrap_or(0.0);
            BOX_MARGIN + max_label_h
        } else {
            0.0
        };
        actor.y = actor_y;
        actor.start_y = actor.y + actor.height; // lifeline starts below actor box
        actor.stop_y = actor.y + actor.height; // will be updated during vertical layout
        actor.margin = margin_rounded;
        prev_x += actor_width + margin_rounded;
    }

    // ── Step 3b: pre-pass to compute loop label widths (like Mermaid's calculateLoopBounds) ──
    // For each loop/alt/opt/par, compute the inner content x-span (from/to) so we can
    // determine whether the label text wraps. This mirrors Mermaid's calculateLoopBounds.
    let loop_label_heights: std::collections::HashMap<usize, f64> = {
        #[derive(Default)]
        struct LoopBound {
            from: f64,
            to: f64,
        }
        let mut stack: Vec<(usize, LoopBound)> = Vec::new(); // (item_idx, bound)
        let mut results: std::collections::HashMap<usize, f64> = std::collections::HashMap::new();

        for (item_idx, item) in diag.items.iter().enumerate() {
            match item {
                SeqItem::LoopStart(_)
                | SeqItem::AltStart(_)
                | SeqItem::OptStart(_)
                | SeqItem::ParStart(_)
                | SeqItem::CriticalStart(_)
                | SeqItem::BreakStart(_)
                | SeqItem::RectStart(_) => {
                    stack.push((
                        item_idx,
                        LoopBound {
                            from: f64::MAX,
                            to: f64::MIN,
                        },
                    ));
                }
                SeqItem::BoxStart { .. } => {
                    // Participant group box — no loop frame for label sizing
                }
                SeqItem::AltElse(_) | SeqItem::ParAnd(_) | SeqItem::CriticalOption(_) => {
                    // section dividers don't pop the frame
                }
                SeqItem::BlockEnd => {
                    if let Some((start_idx, bound)) = stack.pop() {
                        let loop_from = if bound.from == f64::MAX {
                            0.0
                        } else {
                            bound.from
                        };
                        let loop_to = if bound.to == f64::MIN { 0.0 } else { bound.to };
                        // Mermaid: loopWidth = max(0, |to - from|) - labelBoxWidth
                        let inner_span = (loop_to - loop_from).max(0.0);
                        let loop_width = (inner_span - LABEL_BOX_WIDTH).max(0.0);
                        // available text width for label: loopWidth - 2*wrapPadding
                        let available = loop_width - 2.0 * WRAP_PADDING;
                        // Determine label text
                        let label = match &diag.items[start_idx] {
                            SeqItem::LoopStart(l)
                            | SeqItem::AltStart(l)
                            | SeqItem::OptStart(l)
                            | SeqItem::ParStart(l)
                            | SeqItem::CriticalStart(l)
                            | SeqItem::BreakStart(l)
                            | SeqItem::RectStart(l) => l.clone(),
                            SeqItem::BoxStart { label, .. } => label.clone(),
                            _ => String::new(),
                        };
                        // Measure label with brackets, as Mermaid does: wrapLabel('[label]', available, messageFont)
                        let label_with_brackets = if label.is_empty() {
                            String::new()
                        } else {
                            format!("[{label}]")
                        };
                        let (label_w, text_h) = if label_with_brackets.is_empty() {
                            (0.0, 0.0)
                        } else {
                            measure(&label_with_brackets, FONT_SIZE)
                        };
                        // If label_w > available (and available > 0), label wraps — use 2 lines
                        let label_h = if available > 0.0 && label_w > available {
                            2.0 * text_h // 2-line wrap
                        } else if text_h > 0.0 {
                            text_h // single line
                        } else {
                            0.0
                        };
                        results.insert(start_idx, label_h);
                        // Propagate this bound up to the parent frame
                        if let Some((_parent_idx, parent_bound)) = stack.last_mut() {
                            if loop_from < parent_bound.from {
                                parent_bound.from = loop_from;
                            }
                            if loop_to > parent_bound.to {
                                parent_bound.to = loop_to;
                            }
                        }
                    }
                }
                SeqItem::Message(m) => {
                    if let (Some(fa), Some(ta)) = (actor_map.get(&m.from), actor_map.get(&m.to)) {
                        let (msg_from, msg_to) = if m.from == m.to {
                            // self-message: use actor cx ± max(textW/2, actorW/2) like Mermaid
                            let (tw, _) = measure(&m.text, FONT_SIZE);
                            let dx = (tw / 2.0).max(ACTOR_WIDTH / 2.0);
                            let cx = fa.cx();
                            (cx - dx, cx + dx)
                        } else {
                            (fa.cx().min(ta.cx()), fa.cx().max(ta.cx()))
                        };
                        for (_idx, bound) in stack.iter_mut() {
                            if msg_from < bound.from {
                                bound.from = msg_from;
                            }
                            if msg_to > bound.to {
                                bound.to = msg_to;
                            }
                        }
                    }
                }
                SeqItem::Note(n) => {
                    // Notes also expand loop bounds
                    if let Some(fa) = n.actors.first().and_then(|a| actor_map.get(a)) {
                        let note_x1 = fa.x;
                        let note_x2 = fa.x + fa.width;
                        for (_idx, bound) in stack.iter_mut() {
                            if note_x1 < bound.from {
                                bound.from = note_x1;
                            }
                            if note_x2 > bound.to {
                                bound.to = note_x2;
                            }
                        }
                    }
                }
                _ => {}
            }
        }
        results
    };

    // ── Step 4: vertical layout + collect draw commands ──────────────────────
    // Mermaid uses conf.height=65 for initial vertical position regardless of actor type
    // Max visual height is used for the bottom actor stopy calculation
    let _max_actor_visual_h = actor_order
        .iter()
        .map(|n| actor_map[n].height)
        .fold(ACTOR_HEIGHT, f64::max);
    let mut bounds = Bounds {
        start_x: 0.0,
        stop_x: actor_order
            .iter()
            .map(|n| {
                let a = &actor_map[n];
                a.x + a.width
            })
            .fold(0.0f64, f64::max),
        start_y: 0.0,
        stop_y: 0.0,
        // Mermaid: after addActorRenderingData, vp = actor.starty + maxHeight
        // actor.starty = boxMargin + textMaxHeight (when boxes exist), else 0
        // maxHeight = ACTOR_HEIGHT
        vertical_pos: {
            let actor_start_y = if !box_groups.is_empty() {
                let max_label_h = box_groups
                    .iter()
                    .filter(|bg| !bg.label.is_empty())
                    .map(|_| FONT_SIZE)
                    .next()
                    .unwrap_or(0.0);
                BOX_MARGIN + max_label_h
            } else {
                0.0
            };
            actor_start_y + ACTOR_HEIGHT
        },
        loop_stack: Vec::new(),
        activations: Vec::new(),
    };

    let mut msg_models: Vec<MsgModel> = Vec::new();
    let mut note_models: Vec<RenderedNote> = Vec::new();
    let mut control_models: Vec<ControlModel> = Vec::new();
    let mut activation_models: Vec<Activation> = Vec::new();
    let mut auto_number = false;
    let mut seq_idx: usize = 1;

    for (item_idx, item) in diag.items.iter().enumerate() {
        match item {
            SeqItem::AutoNumber { .. } => {
                auto_number = true;
            }

            SeqItem::Activate(actor_name) => {
                if let Some(actor) = actor_map.get(actor_name) {
                    let stack_size = bounds
                        .activations
                        .iter()
                        .filter(|a| &a.actor == actor_name)
                        .count();
                    let ax = actor.cx() + ((stack_size as f64 - 1.0) * ACTIVATION_WIDTH) / 2.0;
                    bounds.activations.push(Activation {
                        actor: actor_name.clone(),
                        start_x: ax,
                        start_y: bounds.vertical_pos + 2.0,
                        stop_x: ax + ACTIVATION_WIDTH,
                        stop_y: 0.0,
                    });
                }
            }

            SeqItem::Deactivate(actor_name) => {
                if let Some(idx) = bounds
                    .activations
                    .iter()
                    .rposition(|a| &a.actor == actor_name)
                {
                    let mut act = bounds.activations.remove(idx);
                    act.stop_y = bounds.vertical_pos;
                    activation_models.push(act);
                }
            }

            SeqItem::Message(m) => {
                // compute start/stop x
                let from_actor = match actor_map.get(&m.from) {
                    Some(a) => a.clone(),
                    None => continue,
                };
                let to_actor = match actor_map.get(&m.to) {
                    Some(a) => a.clone(),
                    None => continue,
                };

                let _is_right = from_actor.cx() <= to_actor.cx();

                // Whether each actor currently has (or will get from this message) an activation.
                let from_has_act = bounds.activations.iter().any(|a| a.actor == m.from);
                let to_has_act = bounds.activations.iter().any(|a| a.actor == m.to);
                let to_gets_act = m.activate > 0; // this message activates the target

                // Activation box left edge = actor.cx - ACTIVATION_WIDTH/2.
                // Reference rule (regardless of stacking depth):
                //   arrow TO activated actor:   stop_x  = act_left - 3  (room for arrowhead)
                //   arrow FROM activated actor: start_x = act_left
                let act_left_of = |actor_cx: f64| actor_cx - ACTIVATION_WIDTH / 2.0;

                let (start_x, stop_x) = if m.from == m.to {
                    (from_actor.cx(), from_actor.cx())
                } else {
                    let sx = if from_has_act {
                        act_left_of(from_actor.cx())
                    } else {
                        from_actor.cx()
                    };
                    // Trim the endpoint so the arrowhead tip lands on the lifeline.
                    // Lines with no marker (Solid/Dotted) need no trim — go to cx - 1.
                    // Lines with a marker need trim equal to the arrowhead overhang.
                    let has_marker = !matches!(
                        m.line_type,
                        LineType::Solid
                            | LineType::Dotted
                            | LineType::SolidCross
                            | LineType::DottedCross
                    );
                    let trim = if has_marker { 4.0 } else { 1.0 };
                    let trim_sign = if from_actor.cx() <= to_actor.cx() {
                        -1.0
                    } else {
                        1.0
                    };
                    let ex = if to_has_act || to_gets_act {
                        act_left_of(to_actor.cx()) + trim_sign * 3.0
                    } else {
                        to_actor.cx() + trim_sign * trim
                    };
                    (sx, ex)
                };

                let from_bounds = from_actor.x.min(to_actor.x);
                let to_bounds = (from_actor.x + from_actor.width).max(to_actor.x + to_actor.width);

                // Vertical positioning — mirrors boundMessage()
                // Mermaid: bump(10), bump(lineHeight), bump(lineHeight - 10 + boxMargin)
                bounds.bump(10.0);
                let (_msg_text_w, _msg_text_h) = measure(&m.text, FONT_SIZE);
                let text_h = 17.0_f64; // browser renders Arial 16px at exactly 17px line height
                bounds.bump(text_h);

                let total_offset = if m.from == m.to {
                    // self-message: totalOffset = lineHeight - 10 + boxMargin + 30
                    text_h - 10.0 + BOX_MARGIN + 30.0
                } else {
                    // normal message: totalOffset = lineHeight - 10 + boxMargin
                    text_h - 10.0 + BOX_MARGIN
                };

                let line_start_y = if m.from == m.to {
                    // self-message: lineStartY = vp + (totalOffset - 30) = vp + text_h - 10 + boxMargin
                    bounds.getvertical() + (total_offset - 30.0)
                } else {
                    bounds.getvertical() + total_offset
                };

                bounds.bump(total_offset);

                let start_y = bounds.getvertical() - total_offset - text_h - 10.0;

                // Update loop frames using cx (lifeline centers), matching Mermaid's
                // calculateLoopBounds which uses msgModel.startx/stopx ≈ actor cx values
                let (frame_x1, frame_x2) = if m.from == m.to {
                    let (tw, _) = measure(&m.text, FONT_SIZE);
                    let dx = (tw / 2.0).max(ACTOR_WIDTH / 2.0);
                    let cx = from_actor.cx();
                    (cx - dx, cx + dx)
                } else {
                    (
                        from_actor.cx().min(to_actor.cx()),
                        from_actor.cx().max(to_actor.cx()),
                    )
                };
                for frame in &mut bounds.loop_stack {
                    frame.from = frame.from.min(frame_x1);
                    frame.to = frame.to.max(frame_x2);
                }

                // Handle activation (+/-) from arrow suffix
                if m.activate > 0 {
                    if let Some(actor) = actor_map.get(&m.to) {
                        let stack_size = bounds
                            .activations
                            .iter()
                            .filter(|a| a.actor == m.to)
                            .count();
                        let ax = actor.cx() + ((stack_size as f64) * ACTIVATION_WIDTH) / 2.0
                            - ACTIVATION_WIDTH / 2.0;
                        bounds.activations.push(Activation {
                            actor: m.to.clone(),
                            start_x: ax,
                            start_y: line_start_y,
                            stop_x: ax + ACTIVATION_WIDTH,
                            stop_y: 0.0,
                        });
                    }
                } else if m.activate < 0 {
                    // `John-->>-Alice` deactivates the SENDER (John = m.from), not the receiver.
                    // The `-` in the syntax means the sending actor's activation ends.
                    if let Some(idx) = bounds.activations.iter().rposition(|a| a.actor == m.from) {
                        let mut act = bounds.activations.remove(idx);
                        act.stop_y = line_start_y;
                        activation_models.push(act);
                    }
                }

                let is_message_line = true;
                let _is_seq = auto_number && is_message_line;

                msg_models.push(MsgModel {
                    from: m.from.clone(),
                    to: m.to.clone(),
                    start_x,
                    stop_x,
                    from_bounds,
                    to_bounds,
                    start_y,
                    line_start_y,
                    text: m.text.clone(),
                    line_type: m.line_type.clone(),
                    activate: m.activate,
                    seq_idx,
                    show_seq: auto_number,
                    height: 0.0,
                });

                // Mermaid also inserts the msgModel bounds at the end of boundMessage
                // For self-messages, additionally insert the visual loop bounds
                // (Mermaid: insert(startx-dx, vp_after_lineH - 10 + totalOffset, stopx+dx, vp_after_lineH + 30 + totalOffset))
                if m.from == m.to {
                    // vp_after_lineH = start_y + 10 + text_h (= bounds before total_offset bump)
                    let vp_mid = start_y + 10.0 + text_h;
                    let self_y1 = vp_mid - 10.0 + total_offset;
                    let self_y2 = vp_mid + 30.0 + total_offset;
                    bounds.insert(start_x, self_y1, stop_x + 60.0, self_y2);
                }
                bounds.insert(from_bounds, start_y, to_bounds, line_start_y);

                seq_idx += 1;
            }

            SeqItem::Note(n) => {
                let from_name = n.actors.first().map(|s| s.as_str()).unwrap_or("");
                let to_name = if n.actors.len() > 1 {
                    n.actors[1].as_str()
                } else {
                    from_name
                };
                let from_actor = match actor_map.get(from_name) {
                    Some(a) => a.clone(),
                    None => continue,
                };
                let to_actor = match actor_map.get(to_name) {
                    Some(a) => a.clone(),
                    None => from_actor.clone(),
                };

                let (note_w, _) = measure(&n.text, FONT_SIZE);
                let note_w_actual = note_w + 2.0 * NOTE_MARGIN;
                // Use browser text height (17px for 16px Arial) to match Mermaid's getBBox() measurement
                let note_h_actual = 17.0 + 2.0 * NOTE_MARGIN;

                let (note_start_x, note_width) = match n.placement {
                    NotePlacement::RightOf => {
                        let sx = from_actor.x + (from_actor.width + ACTOR_MARGIN) / 2.0;
                        let nw = note_w_actual.max(ACTOR_WIDTH);
                        (sx, nw)
                    }
                    NotePlacement::LeftOf => {
                        let nw = note_w_actual.max(ACTOR_WIDTH);
                        let sx = from_actor.x - nw + (from_actor.width - ACTOR_MARGIN) / 2.0;
                        (sx, nw)
                    }
                    NotePlacement::Over => {
                        if from_name == to_name {
                            // single actor
                            let nw = note_w_actual.max(from_actor.width);
                            let sx = from_actor.x + (from_actor.width - nw) / 2.0;
                            (sx, nw)
                        } else {
                            // span two actors
                            let fx = from_actor.x + from_actor.width / 2.0;
                            let tx = to_actor.x + to_actor.width / 2.0;
                            let nw = (fx - tx).abs() + ACTOR_MARGIN;
                            let sx = fx.min(tx) - ACTOR_MARGIN / 2.0;
                            (sx, nw)
                        }
                    }
                };

                bounds.bump(BOX_MARGIN);
                let note_start_y = bounds.getvertical();
                bounds.bump(note_h_actual);
                bounds.insert(
                    note_start_x,
                    note_start_y,
                    note_start_x + note_width,
                    note_start_y + note_h_actual,
                );

                for frame in &mut bounds.loop_stack {
                    frame.from = frame.from.min(note_start_x);
                    frame.to = frame.to.max(note_start_x + note_width);
                }

                note_models.push(RenderedNote {
                    start_x: note_start_x,
                    start_y: note_start_y,
                    width: note_width,
                    height: note_h_actual,
                    text: n.text.clone(),
                });
            }

            SeqItem::LoopStart(label) => {
                // Mermaid: preMargin=boxM, heightAdjust=boxM+boxTextM+max(labelH,labelBoxH)
                let label_h = loop_label_heights.get(&item_idx).copied().unwrap_or(0.0);
                let height_adjust = BOX_MARGIN + BOX_TEXT_MARGIN + label_h.max(LABEL_BOX_HEIGHT);
                bounds.bump(BOX_MARGIN); // preMargin
                let frame_start_y = bounds.getvertical(); // starty = after preMargin
                bounds.bump(height_adjust);
                bounds.loop_stack.push(LoopFrame {
                    label: label.clone(),
                    kind: ControlKind::Loop,
                    from: f64::MAX,
                    to: f64::MIN,
                    start_y: frame_start_y,
                    stop_y: 0.0,
                    sections: Vec::new(),
                    is_alt: false,
                });
            }

            SeqItem::BlockEnd => {
                if let Some(frame) = bounds.loop_stack.pop() {
                    // Mermaid: bump to loopModel.stopy (= frame.stop_y from updateBounds)
                    if frame.stop_y > bounds.vertical_pos {
                        bounds.vertical_pos = frame.stop_y;
                    }
                    let stop_y = bounds.getvertical(); // box bottom = vp (no extra margin)
                    let sx = if frame.from == f64::MAX {
                        bounds.start_x
                    } else {
                        frame.from
                    };
                    let ex = if frame.to == f64::MIN {
                        bounds.stop_x
                    } else {
                        frame.to
                    };
                    control_models.push(ControlModel {
                        kind: frame.kind,
                        label: frame.label,
                        start_x: sx - BOX_MARGIN,
                        stop_x: ex + BOX_MARGIN,
                        start_y: frame.start_y,
                        stop_y,
                        sections: frame.sections,
                    });
                }
            }

            SeqItem::AltStart(label) => {
                let label_h = loop_label_heights.get(&item_idx).copied().unwrap_or(0.0);
                let height_adjust = BOX_MARGIN + BOX_TEXT_MARGIN + label_h.max(LABEL_BOX_HEIGHT);
                bounds.bump(BOX_MARGIN); // preMargin
                let frame_start_y = bounds.getvertical(); // starty = after preMargin
                bounds.bump(height_adjust);
                bounds.loop_stack.push(LoopFrame {
                    label: label.clone(),
                    kind: ControlKind::Alt,
                    from: f64::MAX,
                    to: f64::MIN,
                    start_y: frame_start_y,
                    stop_y: 0.0,
                    sections: Vec::new(),
                    is_alt: true,
                });
            }

            SeqItem::AltElse(label) => {
                // Mermaid: preMargin=boxM+boxTextM=15, heightAdjust=boxM+max(labelH,labelBoxH)=30
                // section divider is at vp + preMargin
                bounds.bump(BOX_MARGIN + BOX_TEXT_MARGIN); // preMargin = 15
                let section_y = bounds.getvertical(); // divider y = after preMargin
                bounds.bump(BOX_MARGIN + LABEL_BOX_HEIGHT); // heightAdjust = 30
                if let Some(frame) = bounds.loop_stack.last_mut() {
                    frame.sections.push(ControlSection {
                        y: section_y,
                        label: label.clone(),
                    });
                }
            }

            SeqItem::OptStart(label) => {
                let label_h = loop_label_heights.get(&item_idx).copied().unwrap_or(0.0);
                let height_adjust = BOX_MARGIN + BOX_TEXT_MARGIN + label_h.max(LABEL_BOX_HEIGHT);
                bounds.bump(BOX_MARGIN); // preMargin
                let frame_start_y = bounds.getvertical(); // starty = after preMargin
                bounds.bump(height_adjust);
                bounds.loop_stack.push(LoopFrame {
                    label: label.clone(),
                    kind: ControlKind::Opt,
                    from: f64::MAX,
                    to: f64::MIN,
                    start_y: frame_start_y,
                    stop_y: 0.0,
                    sections: Vec::new(),
                    is_alt: false,
                });
            }

            SeqItem::ParStart(label) => {
                let label_h = loop_label_heights.get(&item_idx).copied().unwrap_or(0.0);
                let height_adjust = BOX_MARGIN + BOX_TEXT_MARGIN + label_h.max(LABEL_BOX_HEIGHT);
                bounds.bump(BOX_MARGIN); // preMargin
                let frame_start_y = bounds.getvertical(); // starty = after preMargin
                bounds.bump(height_adjust);
                bounds.loop_stack.push(LoopFrame {
                    label: label.clone(),
                    kind: ControlKind::Par,
                    from: f64::MAX,
                    to: f64::MIN,
                    start_y: frame_start_y,
                    stop_y: 0.0,
                    sections: Vec::new(),
                    is_alt: false,
                });
            }

            SeqItem::ParAnd(label) => {
                // Same structure as AltElse
                bounds.bump(BOX_MARGIN + BOX_TEXT_MARGIN); // preMargin = 15
                let section_y = bounds.getvertical(); // divider y = after preMargin
                bounds.bump(BOX_MARGIN + LABEL_BOX_HEIGHT); // heightAdjust = 30
                if let Some(frame) = bounds.loop_stack.last_mut() {
                    frame.sections.push(ControlSection {
                        y: section_y,
                        label: label.clone(),
                    });
                }
            }

            SeqItem::Participant(_) => {} // handled in step 1

            SeqItem::CriticalStart(label) => {
                let label_h = loop_label_heights.get(&item_idx).copied().unwrap_or(0.0);
                let height_adjust = BOX_MARGIN + BOX_TEXT_MARGIN + label_h.max(LABEL_BOX_HEIGHT);
                bounds.bump(BOX_MARGIN);
                let frame_start_y = bounds.getvertical();
                bounds.bump(height_adjust);
                bounds.loop_stack.push(LoopFrame {
                    label: label.clone(),
                    kind: ControlKind::Critical,
                    from: f64::MAX,
                    to: f64::MIN,
                    start_y: frame_start_y,
                    stop_y: 0.0,
                    sections: Vec::new(),
                    is_alt: false,
                });
            }
            SeqItem::BreakStart(label) => {
                let label_h = loop_label_heights.get(&item_idx).copied().unwrap_or(0.0);
                let height_adjust = BOX_MARGIN + BOX_TEXT_MARGIN + label_h.max(LABEL_BOX_HEIGHT);
                bounds.bump(BOX_MARGIN);
                let frame_start_y = bounds.getvertical();
                bounds.bump(height_adjust);
                bounds.loop_stack.push(LoopFrame {
                    label: label.clone(),
                    kind: ControlKind::Break,
                    from: f64::MAX,
                    to: f64::MIN,
                    start_y: frame_start_y,
                    stop_y: 0.0,
                    sections: Vec::new(),
                    is_alt: false,
                });
            }

            SeqItem::CriticalOption(label) => {
                // Same as AltElse
                bounds.bump(BOX_MARGIN + BOX_TEXT_MARGIN);
                let section_y = bounds.getvertical();
                bounds.bump(BOX_MARGIN + LABEL_BOX_HEIGHT);
                if let Some(frame) = bounds.loop_stack.last_mut() {
                    frame.sections.push(ControlSection {
                        y: section_y,
                        label: label.clone(),
                    });
                }
            }

            SeqItem::RectStart(_) => {
                // Rect is a message-flow box (like loop) — bump and push frame
                bounds.bump(BOX_MARGIN);
                let frame_start_y = bounds.getvertical();
                bounds.loop_stack.push(LoopFrame {
                    label: String::new(),
                    kind: ControlKind::Loop,
                    from: f64::MAX,
                    to: f64::MIN,
                    start_y: frame_start_y,
                    stop_y: 0.0,
                    sections: Vec::new(),
                    is_alt: false,
                });
            }
            SeqItem::BoxStart { .. } => {
                // Box is a participant GROUP box — rendered as a background rect behind
                // the actor columns, not as a message-flow box. No loop frame needed.
            }

            SeqItem::Link { .. } => {}
        }
    }

    // Close any unclosed activations
    for mut act in bounds.activations.drain(..) {
        act.stop_y = bounds.vertical_pos;
        activation_models.push(act);
    }

    // Actor bottom y: after content, bump by 2*boxMargin (Mermaid: bumpVerticalPos(boxMargin*2))
    let bottom_actor_y = bounds.vertical_pos + BOX_MARGIN * 2.0;

    // Mermaid stopy = bottom_actor_y + ACTOR_HEIGHT(conf.height=65) + boxMargin
    // Mermaid uses conf.height for the layout/stopy calculation even for actor-man
    let final_stopy = bottom_actor_y + ACTOR_HEIGHT + BOX_MARGIN;

    // Update actor stop_y for lifelines (ends where bottom actor starts)
    for name in &actor_order {
        if let Some(actor) = actor_map.get_mut(name) {
            actor.stop_y = bottom_actor_y;
        }
    }

    // ── Step 5: SVG generation ────────────────────────────────────────────────
    let mut svg_parts: Vec<String> = Vec::new();

    svg_parts.push(String::from("<g></g>"));

    // ── Box group backgrounds (participant groups) ────────────────────────────
    // Rendered first so they appear behind everything else.
    for bg in &box_groups {
        let actor_xs: Vec<f64> = bg
            .actors
            .iter()
            .filter_map(|n| actor_map.get(n))
            .map(|a| a.x)
            .collect();
        let actor_rights: Vec<f64> = bg
            .actors
            .iter()
            .filter_map(|n| actor_map.get(n))
            .map(|a| a.x + a.width)
            .collect();
        if actor_xs.is_empty() {
            continue;
        }
        // Faithful port of Mermaid drawBox:
        //   boxPadding = boxMargin*2 = 20
        //   startx = box.x - boxPadding         (20px left of actors)
        //   starty = box.y - boxPadding*0.25    (5px above actors, actors at y=0)
        //   stopx  = startx + width + 2*boxPadding  (20px right of actors)
        //   stopy  = starty + height + boxPadding*0.75 (15px below bottom actors)
        //   text y = starty + boxTextMargin (5px from top of box)
        const BOX_PADDING: f64 = 20.0; // boxMargin(10) * 2
                                       // box.y = verticalPos when box starts = 0 (before actor_y bump in Mermaid)
                                       // starty = box.y - boxPadding*0.25 = 0 - 5 = -5
                                       // stopy  = starty + height + boxPadding*0.75 = -5 + height + 15
        let actor_x_min = actor_xs.iter().cloned().fold(f64::MAX, f64::min);
        let actor_x_max = actor_rights.iter().cloned().fold(f64::MIN, f64::max);
        let actor_span = actor_x_max - actor_x_min;
        let bx = actor_x_min - BOX_PADDING;
        let box_y = -BOX_PADDING * 0.25; // = -5 (Mermaid: box.y - boxPadding*0.25)
        let bw = actor_span + 2.0 * BOX_PADDING;
        let box_h = final_stopy - box_y + BOX_PADDING * 0.75;
        let fill_color = bg.color.as_deref().unwrap_or("transparent");
        // Center label vertically in the padding area above the actors.
        // box top = box_y = -5, actor top = actor_y. Center = (box_y + actor_y) / 2.
        let actor_top_y = if !box_groups.is_empty() {
            let max_label_h = box_groups
                .iter()
                .filter(|bg2| !bg2.label.is_empty())
                .map(|_| FONT_SIZE)
                .next()
                .unwrap_or(0.0);
            BOX_MARGIN + max_label_h
        } else {
            0.0
        };
        let text_y = (box_y + actor_top_y) / 2.0;
        let label_html = if bg.label.is_empty() {
            String::new()
        } else {
            templates::box_group_label_text(
                bx + bw / 2.0,
                text_y,
                &esc(&bg.label),
                vars.font_family,
            )
        };
        svg_parts.push(templates::box_group_rect(
            bx,
            box_y,
            bw,
            box_h,
            fill_color,
            vars.primary_border,
            &label_html,
        ));
    }

    // Defs (arrow markers)
    // marker_fill: filled arrowheads, crosshead, seqnum circle — git_spine_color matches .marker CSS per theme
    svg_parts.push(defs_svg(diagram_id, vars.signal_color, vars.signal_color));

    // Control structures (loops/alt/opt/par) — collected here, pushed AFTER lifelines
    // so that lifelines are drawn first and the frame borders + badges render on top.
    let control_svgs: Vec<String> = control_models
        .iter()
        .enumerate()
        .map(|(ci, ctrl)| {
            render_control(
                ctrl,
                ci,
                diagram_id,
                vars.primary_color,
                vars.sequence_loop_color,
                vars.signal_color,
                vars.font_family,
            )
        })
        .collect();

    // Messages and notes are deferred to render AFTER lifelines so that sequence
    // number circles (placed at the actor x position on the lifeline) appear on top.
    let msg_svgs: Vec<String> = msg_models
        .iter()
        .map(|mm| {
            render_message(
                mm,
                diagram_id,
                auto_number,
                vars.signal_color,
                vars.text_color,
                if matches!(theme, Theme::Dark) {
                    "black"
                } else {
                    "white"
                },
                vars.signal_color,
                vars.font_family,
            )
        })
        .collect();
    let note_svgs: Vec<String> = note_models
        .iter()
        .map(|n| render_note(n, vars.note_bg, vars.note_border, "black", vars.font_family))
        .collect();

    // Activation boxes are rendered AFTER lifelines (below) so they cover the lifelines.
    // Collect them here; push to svg_parts after the lifelines section.
    activation_models.sort_by(|a, b| {
        a.start_x
            .partial_cmp(&b.start_x)
            .unwrap_or(std::cmp::Ordering::Equal)
    });
    let activation_svgs: Vec<String> = activation_models
        .iter()
        .enumerate()
        .map(|(ai, act)| {
            let cls = format!("activation{}", ai % 3);
            templates::activation_rect(
                act.start_x,
                act.start_y,
                act.stop_x - act.start_x,
                (act.stop_y - act.start_y).max(1.0),
                &cls,
                if matches!(theme, Theme::Dark) {
                    vars.note_bg
                } else {
                    "#f4f4f4"
                },
                if matches!(theme, Theme::Dark) {
                    vars.text_color
                } else {
                    "#666"
                },
            )
        })
        .collect();

    // Collect bottom-row actors for deferred rendering AFTER lifelines.
    // Reference z-order: lifelines first, bottom actors on top → bottom actor
    // head circles cover the end of the lifeline that extends into them.
    let bottom_y = bottom_actor_y;
    let mut bottom_actor_svgs: Vec<String> = Vec::new();
    for (ai, name) in actor_order.iter().enumerate().rev() {
        let actor = &actor_map[name];
        let is_actor_man = matches!(actor.kind, ParticipantKind::Actor);
        if is_actor_man {
            bottom_actor_svgs.push(actor_man_svg(
                actor.cx(),
                bottom_y,
                &actor.alias,
                "actor-bottom",
                ai + actor_order.len(),
                vars.primary_color,
                vars.sequence_actor_border,
                vars.text_color,
                vars.font_family,
            ));
        } else {
            let mut g = String::from("<g>");
            g.push_str(&actor_rect_svg(
                actor.x,
                bottom_y,
                actor.width,
                actor.height,
                &actor.alias,
                "actor-bottom",
                vars.primary_color,
                vars.sequence_actor_border,
            ));
            g.push_str(&actor_text_svg(
                actor.cx(),
                bottom_y + actor.height / 2.0,
                &actor.alias,
                "black",
                vars.font_family,
            ));
            g.push_str("</g>");
            bottom_actor_svgs.push(g);
        }
    }

    // Actors — top row + lifelines
    for (ai, name) in actor_order.iter().enumerate().rev() {
        let actor = &actor_map[name];
        let is_actor_man = matches!(actor.kind, ParticipantKind::Actor);
        let lifeline_start = actor.y + actor.height;
        let lifeline_end = actor.stop_y;

        let mut g = String::from("<g>");
        // Lifeline
        g.push_str(&templates::lifeline(
            ai,
            actor.cx(),
            lifeline_start,
            lifeline_end,
            &esc(&actor.name),
            vars.sequence_actor_border,
        ));
        // Top actor box
        if is_actor_man {
            g.push_str(&actor_man_svg(
                actor.cx(),
                actor.y,
                &actor.alias,
                "actor-top",
                ai,
                vars.primary_color,
                vars.sequence_actor_border,
                vars.text_color,
                vars.font_family,
            ));
        } else {
            g.push_str(&templates::participant_root_group(ai, &esc(&actor.name)));
            g.push_str(&actor_rect_svg(
                actor.x,
                actor.y,
                actor.width,
                actor.height,
                &actor.alias,
                "actor-top",
                vars.primary_color,
                vars.sequence_actor_border,
            ));
            g.push_str(&actor_text_svg(
                actor.cx(),
                actor.y + actor.height / 2.0,
                &actor.alias,
                "black",
                vars.font_family,
            ));
            g.push_str("</g>");
        }
        g.push_str("</g>");
        svg_parts.push(g);
    }

    // Activation boxes — rendered AFTER lifelines so they cover them (correct z-order).
    for act_svg in activation_svgs {
        svg_parts.push(act_svg);
    }

    // Control structures (loops/alt/opt/par) after lifelines — frame borders and
    // badges now render on top of lifelines, covering where they intersect.
    for s in control_svgs {
        svg_parts.push(s);
    }

    // Bottom actors — on top of lifelines so their head circles cover the lifeline end.
    for s in bottom_actor_svgs {
        svg_parts.push(s);
    }

    // Notes — after lifelines/actors so note boxes render on top.
    for s in note_svgs {
        svg_parts.push(s);
    }

    // Messages last — sequence number circles must appear on top of lifelines.
    for s in msg_svgs {
        svg_parts.push(s);
    }

    // ── viewBox / SVG wrapper ─────────────────────────────────────────────────
    // Mermaid formula: height = stopy + 2*diagramMarginY - boxMargin + bottomMarginAdj(1)
    // (mirrorActors=true: height = boxH + 2*DM_Y - boxM + 1 where boxH=stopy-starty=stopy)
    let box_width = bounds.stop_x - bounds.start_x;
    let vb_x = bounds.start_x - DIAGRAM_MARGIN_X;
    let vb_y = -(DIAGRAM_MARGIN_Y as i64);
    let vb_w = box_width + 2.0 * DIAGRAM_MARGIN_X;
    // Mermaid's setupViewBoxHeight: height = stopy + 2*DM_Y - boxM + 1
    // box bottom extends boxPadding*0.75 (=15) below final_stopy when a box exists;
    // we add 10 (not 15) here because grammar ref shows footer→viewBox bottom gap = 21
    // when boxes are present, matching `+11 + 10 = +21` total below stopy.
    let box_bottom_extra = if !box_groups.is_empty() { 10.0 } else { 0.0 };
    let vb_h = final_stopy + 2.0 * DIAGRAM_MARGIN_Y - BOX_MARGIN + 1.0 + box_bottom_extra;
    let max_w = vb_w;

    let body = svg_parts.join("\n");
    let svg_open = templates::svg_root(
        diagram_id,
        max_w as u64,
        vb_x,
        vb_y,
        vb_w as u64,
        vb_h as u64,
    );
    format!("{}\n{}\n</svg>", svg_open, body)
}

// helper that returns current vertical pos
trait GetVertical {
    fn getvertical(&self) -> f64;
}
impl GetVertical for Bounds {
    fn getvertical(&self) -> f64 {
        self.vertical_pos
    }
}

// ── Render helper: control structure (loop/alt/opt/par) ────────────────────

fn control_kind_label(k: &ControlKind) -> &'static str {
    match k {
        ControlKind::Loop => "loop",
        ControlKind::Alt => "alt",
        ControlKind::Opt => "opt",
        ControlKind::Par => "par",
        ControlKind::Critical => "critical",
        ControlKind::Break => "break",
    }
}

#[allow(clippy::too_many_arguments)]
fn render_control(
    ctrl: &ControlModel,
    idx: usize,
    _diagram_id: &str,
    pf: &str,
    pb: &str,
    tc: &str,
    ff: &str,
) -> String {
    let x1 = ctrl.start_x;
    let y1 = ctrl.start_y;
    let x2 = ctrl.stop_x;
    let y2 = ctrl.stop_y;

    // Label box pentagon (Mermaid uses a notched corner)
    let lb_w = LABEL_BOX_WIDTH;
    let lb_h = LABEL_BOX_HEIGHT;
    let notch = 7.0;

    // Pentagon polygon points  (x1,y1) to (x1+lb_w,y1) to (x1+lb_w,y1+lb_h-notch)
    //   to (x1+lb_w-notch*1.2, y1+lb_h) to (x1,y1+lb_h)
    let p1 = format!("{},{}", x1, y1);
    let p2 = format!("{},{}", x1 + lb_w, y1);
    let p3 = format!("{},{}", x1 + lb_w, y1 + lb_h - notch);
    let p4 = format!("{},{}", x1 + lb_w - notch * 1.2, y1 + lb_h);
    let p5 = format!("{},{}", x1, y1 + lb_h);

    let kind_str = control_kind_label(&ctrl.kind);
    let cx_label = x1 + lb_w / 2.0;
    let cy_label = y1 + lb_h / 2.0;

    // Main text (the condition) — centred in the remaining width
    let cx_main = (x1 + lb_w + x2) / 2.0;
    let cy_main = y1 + lb_h / 2.0 + 8.0; // align with label, matching reference y offset

    let mut out = templates::control_group_open(idx, x1, y1, x2, y2, pb);

    // Section dividers
    for sec in &ctrl.sections {
        out.push_str(&templates::control_section_divider(x1, x2, sec.y, pb));
    }

    // Label box + text — inline fill/stroke so the badge has a solid background
    // even when CSS class rules don't apply in the HTML comparison.
    out.push_str(&templates::control_badge(
        &p1,
        &p2,
        &p3,
        &p4,
        &p5,
        cx_label,
        cy_label,
        FONT_SIZE as u32,
        kind_str,
        pf,
        pb,
        tc,
        ff,
    ));

    if !ctrl.label.is_empty() {
        let box_w = x2 - x1;
        let label_text = format!("[{}]", ctrl.label);
        let label_w = crate::text_browser_metrics::measure_browser(&label_text, FONT_SIZE).0;
        if label_w > box_w - 10.0 {
            // Wrap: split on space into two tspans
            let words: Vec<&str> = ctrl.label.split_whitespace().collect();
            let mid = words.len() / 2;
            let line1 = format!("[{}", words[..mid].join(" "));
            let line2 = format!("{}]", words[mid..].join(" "));
            out.push_str(&templates::control_label_text_wrapped(
                cx_main,
                cy_main,
                FONT_SIZE as u32,
                &esc(&line1),
                &esc(&line2),
                tc,
                ff,
            ));
        } else {
            out.push_str(&templates::control_label_text(
                cx_main,
                cy_main,
                FONT_SIZE as u32,
                &esc(&ctrl.label),
                tc,
                ff,
            ));
        }
    }

    // Section title labels
    for sec in &ctrl.sections {
        if !sec.label.is_empty() {
            let sec_cx = (x1 + x2) / 2.0;
            let sec_cy = sec.y + LABEL_BOX_HEIGHT / 2.0 + 5.0;
            out.push_str(&templates::control_section_title(
                sec_cx,
                sec_cy,
                FONT_SIZE as u32,
                &esc(&sec.label),
                tc,
                ff,
            ));
        }
    }

    out.push_str("</g>");
    out
}

// ── Render helper: message ──────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
fn render_message(
    mm: &MsgModel,
    diagram_id: &str,
    auto_number: bool,
    line_color: &str,
    text_color: &str,
    seqnum_text_fill: &str,
    seqnum_circle_fill: &str,
    ff: &str,
) -> String {
    let is_dotted = matches!(
        mm.line_type,
        LineType::Dotted | LineType::DottedArrow | LineType::DottedCross
    );
    let is_self = (mm.start_x - mm.stop_x).abs() < 0.5;

    let line_class = if is_dotted {
        "messageLine1"
    } else {
        "messageLine0"
    };
    let dash_style = if is_dotted {
        r#" style="stroke-dasharray: 3, 3; fill: none;""#
    } else {
        r#" style="fill: none;""#
    };

    // Arrow marker
    let marker = match mm.line_type {
        LineType::SolidArrow | LineType::DottedArrow => {
            format!(r#" marker-end="url(#{}-arrowhead)""#, diagram_id)
        }
        LineType::SolidCross | LineType::DottedCross => {
            format!(r#" marker-end="url(#{}-crosshead)""#, diagram_id)
        }
        LineType::Point => {
            format!(r#" marker-end="url(#{}-filled-head)""#, diagram_id)
        }
        _ => String::new(),
    };

    // Message text position
    let _text_x = if is_self {
        mm.start_x
    } else {
        (mm.start_x + mm.stop_x) / 2.0
    };
    let _text_y =
        mm.line_start_y - mm.line_start_y.fract() + (mm.line_start_y - bounds_start_y_for_text(mm));

    // Position text just above the arrow line — baseline 8px above the line.
    let text_y_actual = mm.line_start_y - 8.0;

    let mut out = String::new();

    // Text label
    out.push_str(&templates::message_label_text(
        (mm.from_bounds + mm.to_bounds) / 2.0,
        text_y_actual,
        FONT_SIZE as u32,
        &esc(&mm.text),
        text_color,
        ff,
    ));

    // Line
    if is_self {
        // Self-message: cubic bezier curve
        let cx1 = mm.start_x + 60.0;
        let cy1 = mm.line_start_y - 10.0;
        let cx2 = mm.start_x + 60.0;
        let cy2 = mm.line_start_y + 30.0;
        out.push_str(&templates::message_self_path(
            mm.start_x,
            mm.line_start_y,
            cx1,
            cy1,
            cx2,
            cy2,
            mm.line_start_y + 20.0,
            line_class,
            mm.seq_idx,
            &esc(&mm.from),
            &esc(&mm.to),
            &marker,
            dash_style,
            line_color,
        ));
    } else {
        out.push_str(&templates::message_line(
            mm.start_x,
            mm.line_start_y,
            mm.stop_x,
            line_class,
            mm.seq_idx,
            &esc(&mm.from),
            &esc(&mm.to),
            &marker,
            dash_style,
            line_color,
        ));
    }

    // Sequence number circle (autonumber) — draw circle+text directly (marker-start
    // on zero-length lines is unreliable across renderers).
    if auto_number && mm.show_seq {
        let cx = mm.start_x;
        let cy = mm.line_start_y;
        out.push_str(&templates::seq_number_circle(cx, cy, seqnum_circle_fill));
        out.push_str(&templates::seq_number_text(
            cx,
            cy,
            mm.seq_idx,
            seqnum_text_fill,
        ));
    }

    out
}

fn bounds_start_y_for_text(mm: &MsgModel) -> f64 {
    // recover text position from line_start_y
    mm.start_y + 10.0
}

// ── Render helper: note ─────────────────────────────────────────────────────

fn render_note(
    note: &RenderedNote,
    note_bg: &str,
    note_border: &str,
    note_text_color: &str,
    ff: &str,
) -> String {
    let mut out = String::new();
    out.push_str(&templates::note_rect(
        note.start_x,
        note.start_y,
        note.width,
        note.height,
        note_bg,
        note_border,
    ));
    out.push_str(&templates::note_text(
        note.start_x + note.width / 2.0,
        note.start_y + note.height / 2.0,
        FONT_SIZE as u32,
        &esc(&note.text),
        note_text_color,
        ff,
    ));
    out
}

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

    const SEQ_BASIC: &str = "sequenceDiagram\n    Alice->>Bob: Hello Bob, how are you?\n    Bob-->>Alice: Great!\n    Alice-)Bob: See you later!";

    #[test]
    fn basic_render_produces_svg() {
        let diag = parser::parse(SEQ_BASIC).diagram;
        let svg = render(&diag, Theme::Default, false);
        assert!(svg.contains("<svg"), "missing <svg tag");
        assert!(svg.contains("Alice"), "missing participant");
        assert!(svg.contains("Bob"), "missing participant");
    }

    #[test]
    fn dark_theme() {
        let diag = parser::parse(SEQ_BASIC).diagram;
        let svg = render(&diag, Theme::Dark, false);
        assert!(svg.contains("<svg"), "missing <svg tag");
    }

    #[test]
    fn snapshot_default_theme() {
        let diag = parser::parse(SEQ_BASIC).diagram;
        let svg = render(&diag, crate::theme::Theme::Default, false);
        insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
    }
}
