use super::constants::*;
use super::parser::{TimelineDiagram, TimelineTask};
use super::templates::{self, esc, node_text_element, text_tspan};

/// Convert HSL (h in degrees, s and l in 0..=1) to linear RGB (each in 0..=1).
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
    let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
    let h_prime = (h / 60.0).rem_euclid(6.0);
    let x = c * (1.0 - (h_prime % 2.0 - 1.0).abs());
    let (r1, g1, b1) = if h_prime < 1.0 {
        (c, x, 0.0)
    } else if h_prime < 2.0 {
        (x, c, 0.0)
    } else if h_prime < 3.0 {
        (0.0, c, x)
    } else if h_prime < 4.0 {
        (0.0, x, c)
    } else if h_prime < 5.0 {
        (x, 0.0, c)
    } else {
        (c, 0.0, x)
    };
    let m = l - c / 2.0;
    (r1 + m, g1 + m, b1 + m)
}

/// Simulate CSS `filter:brightness(120%)` by multiplying each RGB channel by 1.2 (clamped).
/// Handles `hsl(h, s%, l%)` and `#RRGGBB` / `#RGB` hex formats; returns `#RRGGBB`.
fn brighten_color(color: &str) -> String {
    let c = color.trim();
    if let Some(inner) = c.strip_prefix("hsl(").and_then(|s| s.strip_suffix(')')) {
        let parts: Vec<&str> = inner.split(',').collect();
        if parts.len() == 3 {
            if let (Ok(h), Ok(s), Ok(l)) = (
                parts[0].trim().parse::<f64>(),
                parts[1].trim().trim_end_matches('%').parse::<f64>(),
                parts[2].trim().trim_end_matches('%').parse::<f64>(),
            ) {
                let (r, g, b) = hsl_to_rgb(h, s / 100.0, l / 100.0);
                let r2 = ((r * 1.2).min(1.0) * 255.0).round() as u8;
                let g2 = ((g * 1.2).min(1.0) * 255.0).round() as u8;
                let b2 = ((b * 1.2).min(1.0) * 255.0).round() as u8;
                return format!("#{:02x}{:02x}{:02x}", r2, g2, b2);
            }
        }
    }
    if let Some(hex) = c.strip_prefix('#') {
        let (r, g, b) = if hex.len() == 6 {
            let r = u8::from_str_radix(&hex[0..2], 16).ok();
            let g = u8::from_str_radix(&hex[2..4], 16).ok();
            let b = u8::from_str_radix(&hex[4..6], 16).ok();
            (r, g, b)
        } else if hex.len() == 3 {
            let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok();
            let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok();
            let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok();
            (r, g, b)
        } else {
            (None, None, None)
        };
        if let (Some(r), Some(g), Some(b)) = (r, g, b) {
            let r2 = ((r as f64 * 1.2).round() as u32).min(255) as u8;
            let g2 = ((g as f64 * 1.2).round() as u32).min(255) as u8;
            let b2 = ((b as f64 * 1.2).round() as u32).min(255) as u8;
            return format!("#{:02x}{:02x}{:02x}", r2, g2, b2);
        }
    }
    c.to_string()
}
/// Faithful Rust port of Mermaid's timelineRenderer.ts + svgDraw.js.
///
/// Layout algorithm (mirrors the JS draw() function exactly):
///
/// Constants from Mermaid defaults:
///   LEFT_MARGIN = 50   (conf.timeline?.leftMargin ?? 50)
///   masterX starts at 50 + LEFT_MARGIN = 100
///   masterY / sectionBeginY = 50
///   Node width = 150, padding = 20  → rendered width = 150 + 2*20 = 190
///   Step between tasks = 200px (masterX += 200)
///   Step between sections = 200 * max(tasksForSection.length, 1)
///
/// Per-node height: text_height + fontSize*1.1*0.5 + padding, min = maxHeight
///   (faithful to getVirtualNodeHeight / drawNode in svgDraw.js)
///
/// Sections are drawn above tasks; tasks are drawn at masterY + maxSectionHeight + 50.
/// Events are drawn below their task with a dashed vertical line.
///
/// The horizontal activity line is drawn at:
///   depthY = (hasSections) ? maxSectionHeight + maxTaskHeight + 150
///           :                maxTaskHeight + 100
///
/// Section colour palette: 12 colours cycling by section index.
/// Task colour = section colour (same index for all tasks in that section).
/// isWithoutSections: each task gets its own colour index (incremented).
use crate::backends::measure;
use crate::theme::Theme;

// ── Layout constants (faithful to Mermaid timelineRenderer.ts defaults) ────────
// All layout constants are imported from super::constants via `use super::constants::*`.

// ── Mermaid timeline section colour palette (HSL, mirrors JS cScale0..10) ──────
//
// Mermaid generates CSS like:
//   .section--1 path { fill: hsl(240, 100%, 76.27%) }
//   .section-0  path { fill: hsl(60,  100%, 73.53%) }
//   etc.
//
// The hue steps follow Mermaid's cScaleLabel[] / SCALE constant:
//   cScale0 = hsl(240,100%,76.27%)  section--1 (index 0 → class -1)
//   cScale1 = hsl(60, 100%,73.53%)  section-0  (index 1 → class  0)
//   cScale2 = hsl(80, 100%,76.27%)  section-1
//   cScale3 = hsl(270,100%,76.27%)  section-2
//   cScale4 = hsl(300,100%,76.27%)  section-3
//   cScale5 = hsl(330,100%,76.27%)  section-4
//   cScale6 = hsl(0,  100%,76.27%)  section-5
//   cScale7 = hsl(30, 100%,76.27%)  section-6
//   cScale8 = hsl(90, 100%,76.27%)  section-7
//   cScale9 = hsl(150,100%,76.27%)  section-8
//   cScale10= hsl(180,100%,76.27%)  section-9
//   cScale11= hsl(210,100%,76.27%)  section-10
//
// Line colours (hue + 180 for complementary, +10% lightness):
//   cScale0 line: hsl(60,  100%, 86.27%)
//   cScale1 line: hsl(240, 100%, 83.53%)
//   cScale2 line: hsl(260, 100%, 86.27%)
//   cScale3 line: hsl(90,  100%, 86.27%)
//   cScale4 line: hsl(120, 100%, 86.27%)
//   cScale5 line: hsl(150, 100%, 86.27%)
//   cScale6 line: hsl(180, 100%, 86.27%)
//   cScale7 line: hsl(210, 100%, 86.27%)
//   cScale8 line: hsl(270, 100%, 86.27%)
//   cScale9 line: hsl(330, 100%, 86.27%)
//   cScale10 line: hsl(0,  100%, 86.27%)
//   cScale11 line: hsl(30, 100%, 86.27%)
//
// Text colours (white for dark sections, black for light):
//   cScale0 text: #ffffff  (240° blue = dark)
//   cScale1 text: black    (60° yellow = light)
//   cScale2 text: black    (80° lime = light)
//   cScale3 text: #ffffff  (270° purple = dark)
//   cScale4 text: black    (300° pink = medium)
//   cScale5 text: black    (330° rose = medium)
//   cScale6 text: black    (0° red = medium)
//   cScale7 text: black    (30° orange = medium)
//   cScale8 text: black    (90° green = light)
//   cScale9 text: black    (150° teal = medium)
//   cScale10 text: black   (180° cyan = light)
//   cScale11 text: black   (210° blue = medium)

const DARK_FILLS: &[&str] = &[
    "#1f2020", "#0b0000", "#4d1037", "#3f5258", "#4f2f1b", "#6e0a0a", "#3b0048", "#995a01",
    "#154706", "#161722", "#00296f", "#01629c",
];
const DARK_LINES: &[&str] = &[
    "#e0dfdf", "#f4ffff", "#b2efc8", "#c0ada7", "#b0d0e4", "#91f5f5", "#c4ffb7", "#66a5fe",
    "#eab8f9", "#e9e8dd", "#ffd690", "#fe9d63",
];

const FOREST_FILLS: &[&str] = &[
    "hsl(78.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(98.961038961, 100%, 74.9019607843%)",
    "hsl(78.1578947368, 58.4615384615%, 74.5098039216%)",
    "hsl(108.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(138.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(168.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(198.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(228.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(288.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(348.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(18.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(48.1578947368, 58.4615384615%, 64.5098039216%)",
];
const FOREST_LINES: &[&str] = &[
    "hsl(258.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(278.961038961, 100%, 74.9019607843%)",
    "hsl(258.1578947368, 58.4615384615%, 74.5098039216%)",
    "hsl(288.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(318.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(348.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(18.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(48.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(108.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(168.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(198.1578947368, 58.4615384615%, 64.5098039216%)",
    "hsl(228.1578947368, 58.4615384615%, 64.5098039216%)",
];

const NEUTRAL_FILLS: &[&str] = &[
    "#555", "#F4F4F4", "#555", "#BBB", "#777", "#999", "#DDD", "#FFF", "#DDD", "#BBB", "#999",
    "#777",
];
const NEUTRAL_LINES: &[&str] = &[
    "#aaaaaa", "#0b0b0b", "#aaaaaa", "#444444", "#888888", "#666666", "#222222", "#000000",
    "#222222", "#444444", "#666666", "#888888",
];
const NEUTRAL_TEXTS: &[&str] = &[
    "#F4F4F4", "#333", "#F4F4F4", "#333", "#333", "#333", "#333", "#333", "#333", "#333", "#333",
    "#333",
];

fn section_fill(idx: usize, theme: Theme) -> &'static str {
    match theme {
        Theme::Dark => DARK_FILLS[idx % DARK_FILLS.len()],
        Theme::Forest => FOREST_FILLS[idx % FOREST_FILLS.len()],
        Theme::Neutral => NEUTRAL_FILLS[idx % NEUTRAL_FILLS.len()],
        _ => SECTION_STYLES[idx % SECTION_STYLES.len()].fill,
    }
}

fn section_line(idx: usize, theme: Theme) -> &'static str {
    match theme {
        Theme::Dark => DARK_LINES[idx % DARK_LINES.len()],
        Theme::Forest => FOREST_LINES[idx % FOREST_LINES.len()],
        Theme::Neutral => NEUTRAL_LINES[idx % NEUTRAL_LINES.len()],
        _ => SECTION_STYLES[idx % SECTION_STYLES.len()].line,
    }
}

fn section_text(idx: usize, theme: Theme) -> &'static str {
    match theme {
        Theme::Dark => "lightgrey",
        Theme::Forest => "black",
        Theme::Neutral => NEUTRAL_TEXTS[idx % NEUTRAL_TEXTS.len()],
        _ => SECTION_STYLES[idx % SECTION_STYLES.len()].text,
    }
}

// ── Node height computation ────────────────────────────────────────────────────

/// Raw height computation — mirrors JS getVirtualNodeHeight exactly:
///   bbox.height + fontSize * 1.1 * 0.5 + node.padding
/// Does NOT apply node.maxHeight (that is done separately, like JS's drawNode).
fn raw_node_height(text: &str) -> f64 {
    let text_height = wrapped_text_height(text, NODE_WIDTH);
    text_height + FONT_SIZE * 1.1 * 0.5 + NODE_PADDING
}

/// Height for drawing: max(raw_height, max_height).
/// Mirrors drawNode line 978: node.height = Math.max(node.height, node.maxHeight).
fn virtual_node_height(text: &str, max_height: f64) -> f64 {
    raw_node_height(text).max(max_height)
}

/// Text wrapping height, calibrated to match Chromium's SVG getBBox() behaviour.
///
/// Chromium's getBBox for `<text dy="1em"><tspan dy="1em">text</tspan></text>` returns:
///   - 1 line:  FONT_SIZE × 1.0634765625  (= 17.015625 at 16px)
///   - Each additional line adds: FONT_SIZE × 1.1  (= 17.6 at 16px)
///
/// WRAPPING: Mermaid's wrap() uses split(/(\s+|<br>)/) which keeps whitespace
/// tokens. When building a tspan text via line.join(" "), each word boundary
/// gets 3 spaces (1 join + 1 original_space + 1 join), making the effective
/// text wider than single-space joining. We simulate this by using 3 spaces
/// between words when measuring line widths.
///
/// Derivation:
///   Single-line reference task height = 65.815625 = bbox.h + 8.8 + 20 → bbox.h = 17.015625
///   Two-line reference event height = 63.415625 = bbox.h + 8.8 + 20 → bbox.h = 34.615625
///   34.615625 = 17.015625 + 17.6 = first_line + FONT_SIZE × 1.1
fn wrapped_text_height(text: &str, max_width: f64) -> f64 {
    // Calibrated browser heights (empirically derived from reference SVG coordinates)
    let first_line_h = FONT_SIZE * 1.0634765625; // = 17.015625 at 16px
    let additional_line_h = FONT_SIZE * 1.1; // = 17.6 at 16px

    let lines = count_lines(text, max_width);

    if lines == 1 {
        first_line_h
    } else {
        first_line_h + (lines - 1) as f64 * additional_line_h
    }
}

/// Count the number of wrapped lines for `text` at the given `max_width`.
/// Uses 3 spaces between words (simulating Mermaid's wrap() whitespace token behaviour).
fn count_lines(text: &str, max_width: f64) -> usize {
    let words: Vec<&str> = text.split_whitespace().collect();
    if words.is_empty() {
        return 1;
    }

    let mut lines = 1usize;
    let mut current_words: Vec<&str> = Vec::new();

    for word in &words {
        let candidate_words: Vec<&str> = current_words
            .iter()
            .copied()
            .chain(std::iter::once(*word))
            .collect();
        // Mermaid joins with 3 spaces: each boundary = join_space + whitespace_token + join_space
        let candidate_text = candidate_words.join("   ");
        let (w, _) = measure(&candidate_text, FONT_SIZE);
        if w > max_width && !current_words.is_empty() {
            lines += 1;
            current_words = vec![word];
        } else {
            current_words = candidate_words;
        }
    }

    lines
}

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

/// Build the SVG path for a node background.
/// Mirrors defaultBkg in svgDraw.js (r=5, rd=5).
fn node_bg_path(width: f64, height: f64) -> String {
    let r = NODE_CORNER_R;
    let rd = NODE_CORNER_R;
    format!(
        "M0 {h_rd} v{neg_hm2rd} q0,-{r},{r},-{r} h{wm2rd} q{r},0,{r},{r} v{hrd} H0 Z",
        h_rd = height - rd,
        neg_hm2rd = -(height - 2.0 * rd),
        r = r,
        wm2rd = width - 2.0 * rd,
        hrd = height - rd,
    )
}

/// Draw one timeline node (period or event) as SVG.
/// Returns (svg_string, rendered_height).
/// Mirrors drawNode() in svgDraw.js.
///
/// Styling: fill/stroke are applied inline; text fill is set explicitly so
/// it matches regardless of whether a CSS stylesheet is present.
fn draw_node(
    label: &str,
    section_idx: usize,
    node_id: &mut usize,
    max_height: f64,
    is_event: bool,
    theme: Theme,
    shadow_filter: &str,
) -> (String, f64) {
    let width = RENDERED_WIDTH;
    let height = virtual_node_height(label, max_height);
    // JS: (fullSection % maxSections) - 1  where maxSections=12
    let section_class = (section_idx % 12) as i64 - 1;

    let path_d = node_bg_path(width, height);
    let id_val = *node_id;
    *node_id += 1;

    // Text is vertically centered in the top area (10px from top)
    let text_ty = 10.0;
    let text_tx = width / 2.0;

    let text_color = section_text(section_idx, theme);
    let tspans = build_tspans(label, NODE_WIDTH, text_color);

    let mut svg = String::new();
    let base_fill = section_fill(section_idx, theme);
    let base_line = section_line(section_idx, theme);
    let (brightened_fill, brightened_line);
    let (fill, line) = if is_event {
        brightened_fill = brighten_color(base_fill);
        brightened_line = brighten_color(base_line);
        (brightened_fill.as_str(), brightened_line.as_str())
    } else {
        (base_fill, base_line)
    };
    svg.push_str(&templates::node_group_open(section_class));
    svg.push_str("  <g>\n");
    svg.push_str(&templates::node_bg_path(
        id_val,
        &path_d,
        fill,
        shadow_filter,
    ));
    svg.push_str(&templates::node_separator_line(
        section_class,
        height,
        width,
        line,
    ));
    svg.push_str("  </g>\n");
    svg.push_str(&templates::node_text_group(text_tx, text_ty, &tspans));
    svg.push_str("</g>");

    (svg, height)
}

/// Build SVG text element with tspan wrapping for the given text.
/// Uses 3-space joining to match Mermaid's wrap() whitespace token behaviour.
/// `fill` sets the text fill color on the outer `<text>` element.
fn build_tspans(text: &str, max_width: f64, fill: &str) -> String {
    let words: Vec<&str> = text.split_whitespace().collect();
    if words.is_empty() {
        return String::new();
    }

    let line_height_em = 1.1_f64;
    let mut lines: Vec<String> = Vec::new();
    let mut current_words: Vec<&str> = Vec::new();

    for word in &words {
        let candidate_words: Vec<&str> = current_words
            .iter()
            .copied()
            .chain(std::iter::once(*word))
            .collect();
        // Use 3 spaces for width check (matches Mermaid's wrap() join behavior)
        let candidate_3sp = candidate_words.join("   ");
        let (w, _) = measure(&candidate_3sp, FONT_SIZE);
        if w > max_width && !current_words.is_empty() {
            // Push current line with single space (visual rendering)
            lines.push(current_words.join(" "));
            current_words = vec![word];
        } else {
            current_words = candidate_words;
        }
    }
    if !current_words.is_empty() {
        lines.push(current_words.join(" "));
    }

    let mut tspans = String::new();
    for (i, line) in lines.iter().enumerate() {
        let dy = if i == 0 {
            "1em".to_string()
        } else {
            format!("{:.1}em", line_height_em)
        };
        tspans.push_str(&text_tspan(&dy, &esc(line)));
    }
    node_text_element(&tspans, fill)
}

// ── Arrowhead marker ──────────────────────────────────────────────────────────

fn arrowhead_marker(id: &str, line_color: &str, shadow_color: &str) -> String {
    templates::arrowhead_marker(id, line_color, shadow_color)
}

// ── Main render ───────────────────────────────────────────────────────────────

pub fn render(diag: &TimelineDiagram, theme: Theme) -> String {
    let vars = theme.resolve();
    let diagram_id = DIAGRAM_ID;
    let mut node_id: usize = 0;

    let tasks = &diag.tasks;
    let sections = &diag.sections;
    let has_sections = !sections.is_empty();

    // ── Pre-calculate max heights (mirrors the JS height pre-pass) ────────────
    let mut max_section_height: f64 = 0.0;
    let mut max_task_height: f64 = 0.0;
    let mut max_event_line_length: f64 = 0.0;
    let mut max_event_count: usize = 0;

    // Pre-pass: use RAW heights (no max applied), then add 20.
    // Mirrors JS: sectionHeight = getVirtualNodeHeight(...) [no max]; maxSectionHeight = max(max, h+20)
    for section in sections {
        let h = raw_node_height(section);
        max_section_height = max_section_height.max(h + 20.0);
    }

    for task in tasks {
        let h = raw_node_height(&task.task);
        max_task_height = max_task_height.max(h + 20.0);

        max_event_count = max_event_count.max(task.events.len());

        let mut event_line_len: f64 = 0.0;
        for event in &task.events {
            // JS pre-pass uses getVirtualNodeHeight (raw, no maxHeight applied):
            event_line_len += raw_node_height(event);
        }
        if !task.events.is_empty() {
            event_line_len += (task.events.len() - 1) as f64 * 10.0;
        }
        max_event_line_length = max_event_line_length.max(event_line_len);
    }

    // Suppress unused warning — max_event_count mirrors JS but isn't used in layout
    let _ = max_event_count;

    // ── SVG body ─────────────────────────────────────────────────────────────
    let mut parts: Vec<String> = Vec::new();
    let shadow_color = if matches!(theme, Theme::Dark) {
        "rgba(255,255,255,0.15)"
    } else {
        "rgba(185,185,185,1)"
    };
    let shadow_filter = "";
    parts.push(arrowhead_marker(diagram_id, vars.line_color, shadow_color));

    let mut master_x = MASTER_START_X;
    let mut master_y = MASTER_START_Y;
    let section_begin_y = SECTION_START_Y;

    if has_sections {
        for (section_number, section) in sections.iter().enumerate() {
            let tasks_for_section: Vec<&TimelineTask> =
                tasks.iter().filter(|t| t.section == *section).collect();

            // Width spans from the first task center to the last task's right edge:
            // (n-1)*TASK_STEP + RENDERED_WIDTH = 200*(n-1) + 190 = 200*n - 10
            let section_width = 200.0 * (tasks_for_section.len().max(1) as f64) - 10.0;
            let sec_h = virtual_node_height(section, max_section_height).max(max_section_height);

            let sec_svg = draw_section_box(
                section,
                section_number,
                section_width,
                sec_h,
                master_x,
                section_begin_y,
                theme,
            );
            parts.push(sec_svg);

            master_y = section_begin_y + max_section_height + 50.0;

            let task_svgs = draw_tasks(
                tasks_for_section.as_slice(),
                section_number,
                master_x,
                master_y,
                max_task_height,
                max_event_line_length,
                diagram_id,
                &mut node_id,
                false,
                vars.line_color,
                theme,
                shadow_filter,
            );
            parts.extend(task_svgs);

            master_x += 200.0 * (tasks_for_section.len().max(1) as f64);
            master_y = section_begin_y; // mirrors JS: masterY = sectionBeginY (reset for next section)
            let _ = master_y; // suppress unused-assignment warning
        }
    } else {
        let all_tasks: Vec<&TimelineTask> = tasks.iter().collect();
        let task_svgs = draw_tasks(
            all_tasks.as_slice(),
            0,
            master_x,
            master_y,
            max_task_height,
            max_event_line_length,
            diagram_id,
            &mut node_id,
            true,
            vars.line_color,
            theme,
            shadow_filter,
        );
        parts.extend(task_svgs);
        master_x += 200.0 * tasks.len() as f64;
    }

    // ── Activity line ─────────────────────────────────────────────────────────
    // depthY = max_section_height + max_task_height + 150 (with sections)
    //          max_task_height + 100 (no sections)
    let depth_y = if has_sections {
        max_section_height + max_task_height + 150.0
    } else {
        max_task_height + 100.0
    };

    // Mirrors JS: box = getBBox before line; x2 = box.width + 3*LEFT_MARGIN.
    // box.width (before line) = content right edge - content left edge
    //   = (master_x - TASK_STEP + RENDERED_WIDTH) - MASTER_START_X
    //   = master_x - TASK_STEP + RENDERED_WIDTH - MASTER_START_X
    //   = master_x - 200 + 190 - 200 = master_x - 210
    let box_width_before_line = master_x - TASK_STEP + RENDERED_WIDTH - MASTER_START_X;
    let activity_line_x2 = box_width_before_line + 3.0 * LEFT_MARGIN;

    // Activity line (horizontal timeline spine)
    parts.push(templates::activity_line(
        LEFT_MARGIN,
        depth_y,
        activity_line_x2,
        diagram_id,
        vars.line_color,
    ));

    // ── Title ─────────────────────────────────────────────────────────────────
    // Mirrors JS: title.x = box.width / 2 - LEFT_MARGIN (for non-neo look)
    let title_x = box_width_before_line / 2.0 - LEFT_MARGIN;
    let title_svg = if let Some(title) = &diag.title {
        templates::title_text(title_x, &esc(title), vars.text_color)
    } else {
        String::new()
    };

    // ── Viewbox ───────────────────────────────────────────────────────────────
    // Mirrors setupGraphViewbox with padding=50:
    //   After activity line, getBBox: x=LEFT_MARGIN=150 to x=activity_line_x2
    //   getBBox.width = activity_line_x2 - LEFT_MARGIN
    //   vb_x = LEFT_MARGIN - padding = 150 - 50 = 100
    //   vb_w = getBBox.width + 2*padding = (activity_line_x2 - LEFT_MARGIN) + 100
    //   vb_y = svgBounds.y - padding ≈ -60 (title text top in browser + 8px body margin ≈ -10; -10-50=-60)
    //   vb_h = svgBounds.height + 2*padding; svgBounds.height = connector_y2 - svgBounds.y ≈ connector_y2+10
    //   connector_y2 = MASTER_START_Y + max_task_height + 100 + max_event_line_length + 100 (no sections)
    //                  OR section_begin_y + max_section_height + 50 + max_task_height + 200 + max_event_line_length (with sections)
    let vb_x = LEFT_MARGIN - VIEWBOX_PADDING; // 150 - 50 = 100
    let vb_y = -60.0; // = svgBounds.y - padding ≈ -10 - 50 (includes browser body margin ~8px + title ascent ~2px)
    let vb_w = (activity_line_x2 - LEFT_MARGIN) + 2.0 * VIEWBOX_PADDING; // = activity_line_x2 - 150 + 100
                                                                         // connector_y2 = max task_y + maxTaskHeight + 200 + maxEventLineLength
    let task_y = if has_sections {
        SECTION_START_Y + max_section_height + 50.0
    } else {
        MASTER_START_Y
    };
    let connector_y2 = task_y + max_task_height + 200.0 + max_event_line_length;
    // vb_h = (connector_y2 - svgBounds.y) + 2*padding = (connector_y2 + 10) + 100 = connector_y2 + 110
    let vb_h = connector_y2 + 110.0;

    let mut svg = templates::svg_root(diagram_id, vb_w, vb_x, vb_y, vb_w, vb_h, vars.font_family);

    for part in &parts {
        svg.push_str(part);
        svg.push('\n');
    }

    if !title_svg.is_empty() {
        svg.push_str(&title_svg);
        svg.push('\n');
    }

    svg.push_str("</svg>");
    svg
}

// ── Draw a section header box ─────────────────────────────────────────────────

fn draw_section_box(
    label: &str,
    section_idx: usize,
    width: f64,
    height: f64,
    x: f64,
    y: f64,
    theme: Theme,
) -> String {
    let color = section_fill(section_idx, theme);
    let text_color = section_text(section_idx, theme);
    let r = NODE_CORNER_R;
    let rd = NODE_CORNER_R;
    let path = format!(
        "M0 {h_rd} v{neg} q0,-{r},{r},-{r} h{wm} q{r},0,{r},{r} v{hrd} H0 Z",
        h_rd = height - rd,
        neg = -(height - 2.0 * rd),
        r = r,
        wm = width - 2.0 * rd,
        hrd = height - rd,
    );

    let text_x = width / 2.0;
    let text_y = height / 2.0;

    // Bottom line class mirrors the node-line CSS (e.g. node-line--1, node-line-0)
    let line_class_idx = (section_idx % 12) as i64 - 1;
    let line_color = section_line(section_idx, theme);

    templates::section_box(
        x,
        y,
        &path,
        color,
        line_class_idx,
        height,
        width,
        line_color,
        text_x,
        text_y,
        text_color,
        &esc(label),
    )
}

// ── Draw tasks ────────────────────────────────────────────────────────────────

/// Mirrors drawTasks() in timelineRenderer.ts.
#[allow(clippy::too_many_arguments)]
fn draw_tasks(
    tasks: &[&TimelineTask],
    section_color_start: usize,
    mut master_x: f64,
    master_y: f64,
    max_task_height: f64,
    max_event_line_length: f64,
    diagram_id: &str,
    node_id: &mut usize,
    is_without_sections: bool,
    line_color: &str,
    theme: Theme,
    shadow_filter: &str,
) -> Vec<String> {
    let mut parts: Vec<String> = Vec::new();
    let mut section_color_idx = section_color_start;

    for task in tasks {
        let (task_svg, task_h) = draw_node(
            &task.task,
            section_color_idx,
            node_id,
            max_task_height,
            false,
            theme,
            shadow_filter,
        );

        parts.push(templates::task_wrapper(master_x, master_y, &task_svg));

        if !task.events.is_empty() {
            // Connector line: mirrors JS line 1215
            //   y1 = masterY + maxTaskHeight  (masterY = task y = master_y)
            //   y2 = masterY + maxTaskHeight + 100 + maxEventLineLength + 100
            let line_x = master_x + RENDERED_WIDTH / 2.0;
            let line_y1 = master_y + task_h;
            let line_y2 = master_y + max_task_height + 100.0 + max_event_line_length + 100.0;

            parts.push(templates::connector_line(
                line_x, line_y1, line_y2, diagram_id, line_color,
            ));

            // Events: JS does masterY += 100, passes to drawEvents, which does another += 100
            // So first event y = master_y + 100 + 100 = master_y + 200
            let event_start_y = master_y + 100.0; // = JS's (masterY + 100) passed to drawEvents
            draw_events_append(
                &task.events,
                section_color_idx,
                master_x,
                event_start_y,
                node_id,
                &mut parts,
                theme,
                shadow_filter,
            );
        }

        master_x += TASK_STEP;

        if is_without_sections {
            section_color_idx += 1;
        }
    }

    parts
}

// ── Draw events ───────────────────────────────────────────────────────────────

/// Mirrors drawEvents() in timelineRenderer.ts.
#[allow(clippy::too_many_arguments)]
fn draw_events_append(
    events: &[String],
    section_idx: usize,
    master_x: f64,
    start_y: f64,
    node_id: &mut usize,
    parts: &mut Vec<String>,
    theme: Theme,
    shadow_filter: &str,
) {
    // JS drawEvents: masterY = masterY + 100 at start
    let mut current_y = start_y + 100.0;

    for event in events {
        let (event_svg, event_h) = draw_node(
            event,
            section_idx,
            node_id,
            50.0,
            true,
            theme,
            shadow_filter,
        );

        parts.push(templates::event_wrapper(master_x, current_y, &event_svg));

        current_y += 10.0 + event_h;
    }
}

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

    #[test]
    fn basic_render_produces_svg() {
        let input = concat!(
            "timeline\n",
            "    title History of Social Media\n",
            "    2002 : LinkedIn\n",
            "    2004 : Facebook\n",
            "         : Google\n",
        );
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, Theme::Default);
        assert!(svg.contains("<svg"), "missing <svg tag");
        assert!(svg.contains("LinkedIn"));
        assert!(svg.contains("Facebook"));
        assert!(svg.contains("Google"));
        assert!(svg.contains("History of Social Media"));
        assert!(svg.contains("2002"));
        assert!(svg.contains("2004"));
    }

    #[test]
    fn with_sections_renders() {
        let input = concat!(
            "timeline\n",
            "    title History of Social Media Platform\n",
            "    section ICT and Internet\n",
            "        1978 : first commercial social network\n",
            "        1994 : GeoCities\n",
        );
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, Theme::Default);
        assert!(svg.contains("ICT and Internet"));
        assert!(svg.contains("1978"));
        assert!(svg.contains("GeoCities"));
    }

    #[test]
    fn activity_line_present() {
        let input = "timeline\n    2002 : LinkedIn\n";
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, Theme::Default);
        assert!(svg.contains("stroke-width:4")); // inline style format
    }

    #[test]
    fn node_bg_path_roundtrip() {
        let p = node_bg_path(190.0, 60.0);
        assert!(p.starts_with("M0 "));
        assert!(p.contains('q'));
        assert!(p.ends_with('Z'));
    }

    #[test]
    fn full_example_with_sections() {
        // Tasks that have a section keyword get drawn under their section header.
        // Tasks without a section (before any 'section' keyword) are in section=""
        // which is not in the sections list, so only the sectioned tasks are drawn.
        let input = concat!(
            "timeline\n",
            "    title History of Social Media Platform\n",
            "    section ICT and Internet\n",
            "        1978 : first commercial social network\n",
            "        1994 : GeoCities\n",
            "    section Social Media\n",
            "        2002 : LinkedIn\n",
            "        2004 : Facebook\n",
            "             : Google\n",
        );
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, Theme::Default);
        assert!(svg.contains("ICT and Internet"));
        assert!(svg.contains("GeoCities"));
        assert!(svg.contains("LinkedIn"));
        assert!(svg.contains("Facebook"));
        assert!(svg.contains("Google"));
    }

    #[test]
    fn full_example_no_sections() {
        // All tasks without any section keyword.
        let input = concat!(
            "timeline\n",
            "    title History of Social Media Platform\n",
            "    2002 : LinkedIn\n",
            "    2004 : Facebook\n",
            "         : Google\n",
            "    2005 : YouTube\n",
            "    2006 : Twitter\n",
        );
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, Theme::Default);
        assert!(svg.contains("YouTube"));
        assert!(svg.contains("Twitter"));
        assert!(svg.contains("LinkedIn"));
        assert!(svg.contains("Facebook"));
    }

    #[test]
    fn snapshot_default_theme() {
        let input =
            "timeline\n    title History of Social Media\n    2002 : LinkedIn\n    2004 : Facebook";
        let diag = parser::parse(input).diagram;
        let svg = render(&diag, crate::theme::Theme::Default);
        insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
    }
}
