//! SVG serializer for molecular 2D layouts.
//!
//! Converts a `Layout` (atom coordinates) plus a `Molecule` (atoms, bonds)
//! into a self-contained SVG string suitable for embedding in HTML or saving
//! as a `.svg` file.

use chematic_core::{apply_kekule, kekulize, AtomIdx, BondIdx, BondOrder, Molecule};

use crate::layout::{Layout, Point, BOND_LEN};

/// Font size used for atom labels, in SVG pixels.
const FONT_SIZE: f64 = 12.0;

/// Approximate half-width of a label background rectangle.
const LABEL_HALF_W: f64 = 8.0;

/// Approximate half-height of a label background rectangle.
const LABEL_HALF_H: f64 = 7.0;

// ---------------------------------------------------------------------------
// Public options
// ---------------------------------------------------------------------------

/// Options controlling SVG rendering style.
#[derive(Clone, Debug)]
pub struct RenderOptions {
    /// Override SVG `width` attribute (px). `None` = auto from bounding box.
    pub width: Option<u32>,
    /// Override SVG `height` attribute (px). `None` = auto from bounding box.
    pub height: Option<u32>,
    /// Padding around the molecule bounding box (SVG user units, default 20.0).
    pub padding: f64,
    /// Background fill color.  `"transparent"` suppresses the background rect
    /// and also removes per-label background rectangles so bonds show through.
    /// Default: `"white"`.
    pub background: String,
    /// Dark-theme mode: bonds rendered in white, carbon text in white.
    /// Default: `false`.
    pub dark: bool,
    /// Atom indices to highlight (yellow circle background by default).
    pub highlight_atoms: std::collections::HashSet<AtomIdx>,
    /// Bond indices to highlight (orange thick line by default).
    pub highlight_bonds: std::collections::HashSet<BondIdx>,
    /// Highlight color (CSS hex, default `"#FFFF00"`).
    pub highlight_color: String,
    /// Per-atom color overrides.  Keys are atom indices; values are CSS colors.
    /// Takes precedence over [`highlight_color`] when both apply to the same atom.
    /// Atoms in this map are highlighted even if absent from [`highlight_atoms`].
    pub atom_color_map: std::collections::HashMap<AtomIdx, String>,
    /// Attach `data-atom-idx`, `data-element`, `data-charge` attributes to
    /// atom label `<text>` elements, enabling JS hover/click tooltips.
    /// Default: `false`.
    pub atom_ids: bool,
    /// Overlay atom index numbers (0-based) as small grey text near each atom.
    /// Default: `false`.
    pub show_atom_indices: bool,
    /// Render aromatic bonds as Kekulé (alternating single/double) instead of
    /// the default dashed aromatic style.
    /// Default: `false`.
    pub kekulize: bool,
}

impl Default for RenderOptions {
    fn default() -> Self {
        Self {
            width: None,
            height: None,
            padding: 20.0,
            background: "white".into(),
            dark: false,
            highlight_atoms: std::collections::HashSet::new(),
            highlight_bonds: std::collections::HashSet::new(),
            highlight_color: "#FFFF00".into(),
            atom_color_map: std::collections::HashMap::new(),
            atom_ids: false,
            show_atom_indices: false,
            kekulize: false,
        }
    }
}

// ---------------------------------------------------------------------------
// Internal draw context
// ---------------------------------------------------------------------------

struct DrawCtx<'a> {
    bond_color: &'a str,
    label_rect_fill: Option<&'a str>, // None = skip background rect
    dark: bool,
    atom_ids: bool,
    show_atom_indices: bool,
}

impl<'a> DrawCtx<'a> {
    fn from_opts(opts: &'a RenderOptions) -> Self {
        let bond_color = if opts.dark { "white" } else { "black" };
        let label_rect_fill = if opts.background == "transparent" {
            None
        } else {
            Some(opts.background.as_str())
        };
        DrawCtx { bond_color, label_rect_fill, dark: opts.dark, atom_ids: opts.atom_ids, show_atom_indices: opts.show_atom_indices }
    }

    fn text_color(&self, atomic_number: u8) -> &str {
        if self.dark && atomic_number == 6 {
            "white"
        } else {
            atom_color(atomic_number)
        }
    }
}

// ---------------------------------------------------------------------------
// Entry points
// ---------------------------------------------------------------------------

/// Render just the bonds and atom labels for `mol` without an SVG wrapper.
///
/// Used by the grid renderer to compose multiple molecules into one SVG.
pub(crate) fn render_mol_body(mol: &Molecule, layout: &Layout) -> String {
    let ctx = DrawCtx { bond_color: "black", label_rect_fill: Some("white"), dark: false, atom_ids: false, show_atom_indices: false };
    let mut body = String::new();
    for (_, bond) in mol.bonds() {
        let p1 = layout.get(bond.atom1);
        let p2 = layout.get(bond.atom2);
        body.push_str(&render_bond_c(bond.order, p1, p2, ctx.bond_color));
    }
    write_atom_labels_ctx(mol, layout, &ctx, &mut body);
    body
}

/// Render `mol` with the given `layout` as a self-contained SVG string.
pub fn render_svg(mol: &Molecule, layout: &Layout) -> String {
    render_svg_opts(mol, layout, &RenderOptions::default())
}

/// Render `mol` with highlighted atoms and bonds.
///
/// An empty `highlight_atoms`/`highlight_bonds` produces the same output
/// as [`render_svg`].
pub fn render_svg_highlighted(
    mol: &Molecule,
    layout: &Layout,
    highlight_atoms: &std::collections::HashSet<AtomIdx>,
    highlight_bonds: &std::collections::HashSet<BondIdx>,
) -> String {
    let opts = RenderOptions {
        highlight_atoms: highlight_atoms.clone(),
        highlight_bonds: highlight_bonds.clone(),
        ..RenderOptions::default()
    };
    render_svg_opts(mol, layout, &opts)
}

/// Render `mol` with full control over style via [`RenderOptions`].
pub fn render_svg_opts(mol: &Molecule, layout: &Layout, opts: &RenderOptions) -> String {
    // R4: cleaner than the uninit-borrow dance; kekulize failure silently falls back to aromatic.
    let maybe_kekule: Option<Molecule> = if opts.kekulize {
        kekulize(mol).ok().map(|kr| apply_kekule(mol, &kr))
    } else {
        None
    };
    let mol: &Molecule = maybe_kekule.as_ref().unwrap_or(mol);

    let ctx = DrawCtx::from_opts(opts);
    let mut svg = String::new();

    write_svg_header_opts(layout, opts, &mut svg);

    // Highlight atom circles (beneath bonds).
    // Union of highlight_atoms and atom_color_map keys; per-atom color overrides highlight_color.
    let atom_count = mol.atom_count();
    let default_hc = escape_xml(&opts.highlight_color);
    let mut atom_circles: Vec<(AtomIdx, String)> = Vec::new();
    for idx in &opts.highlight_atoms {
        if idx.0 as usize >= atom_count { continue; }
        let color = opts.atom_color_map.get(idx)
            .map(|c| escape_xml(c))
            .unwrap_or_else(|| default_hc.clone());
        atom_circles.push((*idx, color));
    }
    for (idx, color) in &opts.atom_color_map {
        if idx.0 as usize >= atom_count { continue; }
        if !opts.highlight_atoms.contains(idx) {
            atom_circles.push((*idx, escape_xml(color)));
        }
    }
    atom_circles.sort_unstable_by_key(|(idx, _)| *idx);
    for (idx, color) in &atom_circles {
        let p = layout.get(*idx);
        svg.push_str(&format!(
            "  <circle cx=\"{:.2}\" cy=\"{:.2}\" r=\"16\" fill=\"{}\" opacity=\"0.5\"/>\n",
            p.x, p.y, color
        ));
    }

    // Bonds.
    for (bond_idx, bond) in mol.bonds() {
        let p1 = layout.get(bond.atom1);
        let p2 = layout.get(bond.atom2);
        if opts.highlight_bonds.contains(&bond_idx) {
            svg.push_str(&render_line(p1, p2, "4.0", "#FF8C00")); // R1
        } else {
            svg.push_str(&render_bond_c(bond.order, p1, p2, ctx.bond_color));
        }
    }

    write_atom_labels_ctx(mol, layout, &ctx, &mut svg);

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

// ---------------------------------------------------------------------------
// SVG header
// ---------------------------------------------------------------------------

fn write_svg_header_opts(layout: &Layout, opts: &RenderOptions, svg: &mut String) {
    let padding = opts.padding;
    let (min_x, min_y, max_x, max_y) = layout.bounding_box();

    let raw_w = (max_x - min_x).max(BOND_LEN);
    let raw_h = (max_y - min_y).max(BOND_LEN);

    let view_x = min_x - padding;
    let view_y = min_y - padding;
    let view_w = raw_w + 2.0 * padding;
    let view_h = raw_h + 2.0 * padding;

    let display_w = opts.width.unwrap_or_else(|| view_w.round() as u32);
    let display_h = opts.height.unwrap_or_else(|| view_h.round() as u32);

    svg.push_str(&format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" \
         width=\"{}\" height=\"{}\" \
         viewBox=\"{:.2} {:.2} {:.2} {:.2}\">\n",
        display_w, display_h, view_x, view_y, view_w, view_h
    ));

    if opts.background != "transparent" {
        svg.push_str(&format!(
            "  <rect x=\"{:.2}\" y=\"{:.2}\" width=\"{:.2}\" height=\"{:.2}\" fill=\"{}\"/>\n",
            view_x, view_y, view_w, view_h, escape_xml(&opts.background) // S1: escape user input
        ));
    }
}

// ---------------------------------------------------------------------------
// Atom labels
// ---------------------------------------------------------------------------

fn write_atom_labels_ctx(mol: &Molecule, layout: &Layout, ctx: &DrawCtx, svg: &mut String) {
    for (idx, atom) in mol.atoms() {
        let label = atom_label(mol, idx);
        let p = layout.get(idx);

        if !label.is_empty() {
            if let Some(fill) = ctx.label_rect_fill {
                svg.push_str(&format!(
                    "  <rect x=\"{:.2}\" y=\"{:.2}\" width=\"{:.2}\" height=\"{:.2}\" fill=\"{}\"/>\n",
                    p.x - LABEL_HALF_W,
                    p.y - LABEL_HALF_H,
                    LABEL_HALF_W * 2.0,
                    LABEL_HALF_H * 2.0,
                    escape_xml(fill), // S1: escape background colour
                ));
            }

            // R2: build optional data attrs, then single format!
            let data_attrs = if ctx.atom_ids {
                format!(
                    " data-atom-idx=\"{}\" data-element=\"{}\" data-charge=\"{}\"",
                    idx.0, atom.element.symbol(), atom.charge
                )
            } else {
                String::new()
            };
            svg.push_str(&format!(
                "  <text x=\"{:.2}\" y=\"{:.2}\" \
                 font-family=\"sans-serif\" font-size=\"{}\" \
                 text-anchor=\"middle\" dominant-baseline=\"central\" \
                 fill=\"{}\"{}>{}</text>\n",
                p.x, p.y,
                FONT_SIZE as u32,
                ctx.text_color(atom.element.atomic_number()),
                data_attrs,
                escape_xml(&label)
            ));
        } else if ctx.atom_ids {
            // B3: unlabelled atoms (plain carbons) get an invisible anchor so JS can address them.
            svg.push_str(&format!(
                "  <text x=\"{:.2}\" y=\"{:.2}\" font-size=\"0\" \
                 data-atom-idx=\"{}\" data-element=\"{}\" data-charge=\"{}\"/>\n",
                p.x, p.y, idx.0, atom.element.symbol(), atom.charge
            ));
        }

        // Atom index overlay (all atoms, including unlabelled carbons).
        if ctx.show_atom_indices {
            svg.push_str(&format!(
                "  <text x=\"{:.2}\" y=\"{:.2}\" \
                 font-family=\"sans-serif\" font-size=\"8\" \
                 text-anchor=\"start\" dominant-baseline=\"auto\" \
                 fill=\"#8b92a9\">{}</text>\n",
                p.x + LABEL_HALF_W,
                p.y - LABEL_HALF_H,
                idx.0
            ));
        }
    }
}

// ---------------------------------------------------------------------------
// Bond rendering
// ---------------------------------------------------------------------------

fn render_bond_c(order: BondOrder, p1: Point, p2: Point, color: &str) -> String {
    match order {
        BondOrder::Single   => render_line(p1, p2, "1.5", color),
        BondOrder::Up       => render_wedge_up(p1, p2, color),
        BondOrder::Down     => render_dash_bond(p1, p2, color),
        BondOrder::Double   => render_double_bond(p1, p2, color),
        BondOrder::Triple   => render_triple_bond(p1, p2, color),
        BondOrder::Aromatic => render_aromatic_bond(p1, p2, color),
        BondOrder::Quadruple => render_line(p1, p2, "3.0", color),
    }
}

fn render_line(p1: Point, p2: Point, stroke_width: &str, color: &str) -> String {
    format!(
        "  <line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
         stroke=\"{}\" stroke-width=\"{}\" fill=\"none\"/>\n",
        p1.x, p1.y, p2.x, p2.y, color, stroke_width
    )
}

fn perp_unit(p1: Point, p2: Point) -> (f64, f64) {
    let dx = p2.x - p1.x;
    let dy = p2.y - p1.y;
    let len = (dx * dx + dy * dy).sqrt();
    if len < 1e-10 {
        return (0.0, 1.0);
    }
    (-dy / len, dx / len)
}

fn render_double_bond(p1: Point, p2: Point, color: &str) -> String {
    let offset = 2.0;
    let (px, py) = perp_unit(p1, p2);
    let mut s = String::new();
    for sign in [-1.0_f64, 1.0] {
        s.push_str(&render_line(
            Point::new(p1.x + px * offset * sign, p1.y + py * offset * sign),
            Point::new(p2.x + px * offset * sign, p2.y + py * offset * sign),
            "1.5", color,
        ));
    }
    s
}

fn render_triple_bond(p1: Point, p2: Point, color: &str) -> String {
    let (px, py) = perp_unit(p1, p2);
    let mut s = String::new();
    for &offset in &[0.0_f64, -3.0, 3.0] {
        s.push_str(&render_line(
            Point::new(p1.x + px * offset, p1.y + py * offset),
            Point::new(p2.x + px * offset, p2.y + py * offset),
            "1.5", color,
        ));
    }
    s
}

fn render_line_dashed(p1: Point, p2: Point, stroke_width: &str, color: &str, dasharray: &str) -> String {
    format!(
        "  <line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
         stroke=\"{}\" stroke-width=\"{}\" fill=\"none\" stroke-dasharray=\"{}\"/>\n",
        p1.x, p1.y, p2.x, p2.y, color, stroke_width, dasharray
    )
}

fn render_aromatic_bond(p1: Point, p2: Point, color: &str) -> String {
    let offset = 2.0;
    let (px, py) = perp_unit(p1, p2);
    let mut s = String::new();
    s.push_str(&render_line(
        Point::new(p1.x - px * offset, p1.y - py * offset),
        Point::new(p2.x - px * offset, p2.y - py * offset),
        "1.5", color,
    ));
    s.push_str(&render_line_dashed(
        Point::new(p1.x + px * offset, p1.y + py * offset),
        Point::new(p2.x + px * offset, p2.y + py * offset),
        "1.5", color, "4,3",
    ));
    s
}

fn render_wedge_up(p1: Point, p2: Point, color: &str) -> String {
    let (px, py) = perp_unit(p1, p2);
    let half_w = 3.0;
    let x2a = p2.x - px * half_w;
    let y2a = p2.y - py * half_w;
    let x2b = p2.x + px * half_w;
    let y2b = p2.y + py * half_w;
    format!(
        "  <polygon points=\"{:.2},{:.2} {:.2},{:.2} {:.2},{:.2}\" \
         fill=\"{}\" stroke=\"{}\" stroke-width=\"0.5\"/>\n",
        p1.x, p1.y, x2a, y2a, x2b, y2b, color, color
    )
}

fn render_dash_bond(p1: Point, p2: Point, color: &str) -> String {
    let dx = p2.x - p1.x;
    let dy = p2.y - p1.y;
    let len = (dx * dx + dy * dy).sqrt();
    if len < 1e-10 {
        return String::new();
    }
    let (px, py) = perp_unit(p1, p2);
    let steps = 6usize;
    let mut s = String::new();
    for i in 0..=steps {
        let t = i as f64 / steps as f64;
        let cx = p1.x + t * dx;
        let cy = p1.y + t * dy;
        let hw = t * 3.0 + 0.5;
        s.push_str(&render_line(
            Point::new(cx - px * hw, cy - py * hw),
            Point::new(cx + px * hw, cy + py * hw),
            "1.0", color,
        ));
    }
    s
}

// ---------------------------------------------------------------------------
// Atom coloring (CPK palette)
// ---------------------------------------------------------------------------

fn atom_color(atomic_number: u8) -> &'static str {
    match atomic_number {
        7  => "#3050F8", // N  blue
        8  => "#FF0D0D", // O  red
        16 => "#FFFF30", // S  yellow
        17 => "#1FF01F", // Cl green
        9  => "#90E050", // F  light-green
        35 => "#A62929", // Br dark-red/brown
        53 => "#940094", // I  purple
        15 => "#FF8000", // P  orange
        _  => "#000000", // default black
    }
}

// ---------------------------------------------------------------------------
// Atom labels
// ---------------------------------------------------------------------------

/// Format a formal charge as a suffix string.
/// Centralised here so the character used for minus is consistent everywhere (B5, R3).
fn format_charge(c: i8) -> String {
    match c {
        0        => String::new(),
        1        => "+".to_string(),
        -1       => "-".to_string(),
        c if c > 1 => format!("{c}+"),
        c        => format!("{}-", -c), // e.g. "2-", "3-"
    }
}

/// Compute the display label for an atom.
///
/// Isolated atoms (single-atom molecule or degree-0 in a multi-atom molecule)
/// use Hill-notation molecular-formula style: H2O, CH4, NH3.
/// Plain carbons in bonded multi-atom molecules return "".
fn atom_label(mol: &Molecule, idx: AtomIdx) -> String {
    let atom = mol.atom(idx);
    let is_carbon = atom.element.atomic_number() == 6;
    let has_charge = atom.charge != 0;
    let has_isotope = atom.isotope.is_some();

    // Isolated atom: use molecular-formula style (H2O, CH4, etc.).
    // This covers both single-atom molecules and degree-0 atoms in disconnected SMILES (B4).
    if mol.atom_count() == 1 || mol.degree(idx) == 0 {
        let h = chematic_core::implicit_hcount(mol, idx);
        return build_isolated_label(
            atom.element.symbol(),
            atom.element.atomic_number(),
            h,
            atom.charge,
        );
    }

    // Plain carbon in a bonded multi-atom molecule: no label in skeletal structure.
    if is_carbon && !has_charge && !has_isotope {
        return String::new();
    }

    let mut label = atom.element.symbol().to_string();

    // Implicit H count for non-carbon atoms.
    if !is_carbon {
        let h = chematic_core::implicit_hcount(mol, idx);
        if h == 1 {
            label.push('H');
        } else if h > 1 {
            label.push('H');
            label.push_str(&h.to_string());
        }
    }

    // Charge (R3: use shared helper; B5: consistent ASCII minus).
    if has_charge {
        label.push_str(&format_charge(atom.charge));
    }

    label
}

/// Build a molecular-formula-style label for an isolated atom.
///
/// Hill notation: C first, then H, then alphabetical.
/// Examples: C→"CH4", O→"H2O", N→"H3N", S→"H2S", noble gas→"He"
fn build_isolated_label(symbol: &str, atomic_number: u8, h: u8, charge: i8) -> String {
    let base = match atomic_number {
        6 => match h {
            0 => symbol.to_string(),
            1 => format!("{}H", symbol),
            n => format!("{}H{}", symbol, n),
        },
        _ => match h {
            0 => symbol.to_string(),
            1 => format!("H{}", symbol),
            n => format!("H{}{}", n, symbol),
        },
    };

    if charge == 0 {
        return base;
    }
    format!("{}{}", base, format_charge(charge)) // R3, B5
}

/// Escape XML special characters in a label string.
fn escape_xml(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn mol(s: &str) -> chematic_core::Molecule {
        parse(s).unwrap_or_else(|e| panic!("parse '{s}': {e}"))
    }

    #[test]
    fn test_perp_unit_horizontal() {
        let (px, py) = perp_unit(Point::new(0.0, 0.0), Point::new(1.0, 0.0));
        assert!((px.abs() - 0.0).abs() < 1e-9);
        assert!((py.abs() - 1.0).abs() < 1e-9);
    }

    #[test]
    fn isolated_water_label_h2o() {
        let m = mol("O");
        let layout = crate::layout::compute_layout(&m);
        let svg = render_svg(&m, &layout);
        assert!(svg.contains("H2O"), "water 'O' should show H2O label, got: {}", svg);
    }

    #[test]
    fn isolated_methane_label_ch4() {
        let m = mol("C");
        let layout = crate::layout::compute_layout(&m);
        let svg = render_svg(&m, &layout);
        assert!(svg.contains("CH4"), "methane 'C' should show CH4 label, got: {}", svg);
        assert!(svg.contains("<text"), "single C must have a text label now");
    }

    #[test]
    fn isolated_ammonia_label_h3n() {
        let m = mol("N");
        let layout = crate::layout::compute_layout(&m);
        let svg = render_svg(&m, &layout);
        assert!(svg.contains("H3N"), "ammonia 'N' should show H3N label");
    }

    #[test]
    fn multi_atom_carbon_no_label() {
        let m = mol("CC");
        let layout = crate::layout::compute_layout(&m);
        let svg = render_svg(&m, &layout);
        assert!(!svg.contains("<text"), "ethane should have no atom labels");
    }

    #[test]
    fn render_opts_transparent_no_bg_rect() {
        let m = mol("c1ccccc1");
        let layout = crate::layout::compute_layout(&m);
        let opts = RenderOptions { background: "transparent".into(), ..Default::default() };
        let svg = render_svg_opts(&m, &layout, &opts);
        assert!(svg.contains("<svg"), "must be valid SVG");
        // No background rect when transparent.
        assert!(!svg.contains("fill=\"transparent\""), "no bg rect fill for transparent");
    }

    #[test]
    fn render_opts_custom_size() {
        let m = mol("CCO");
        let layout = crate::layout::compute_layout(&m);
        let opts = RenderOptions { width: Some(300), height: Some(200), ..Default::default() };
        let svg = render_svg_opts(&m, &layout, &opts);
        assert!(svg.contains("width=\"300\""), "SVG width should be 300");
        assert!(svg.contains("height=\"200\""), "SVG height should be 200");
    }

    #[test]
    fn render_opts_dark_theme_white_bonds() {
        let m = mol("CC");
        let layout = crate::layout::compute_layout(&m);
        let opts = RenderOptions { dark: true, background: "#0f172a".into(), ..Default::default() };
        let svg = render_svg_opts(&m, &layout, &opts);
        assert!(svg.contains("stroke=\"white\""), "dark theme bonds should be white");
    }

    #[test]
    fn render_opts_highlight_atoms() {
        let m = mol("c1ccncc1");
        let layout = crate::layout::compute_layout(&m);
        let n_idx = m.atoms()
            .find(|(_, a)| a.element.atomic_number() == 7)
            .map(|(idx, _)| idx)
            .expect("pyridine has N");
        let mut hl = std::collections::HashSet::new();
        hl.insert(n_idx);
        let opts = RenderOptions { highlight_atoms: hl, ..Default::default() };
        let svg = render_svg_opts(&m, &layout, &opts);
        assert!(svg.contains("<circle"), "highlight must produce a circle");
    }
}
