"""
SBMLLayout.renderer — Skia-based rendering backend for SBML Layout.

Reads geometry from the SBML Layout extension and produces publication-quality
diagrams with full cubic bezier support (two control points per arm).

Supports PNG, PDF, and SVG output.  Displays inline in Jupyter notebooks.

Design notes
------------
* All geometry is read from the SBML Layout extension, so the renderer works
  with any SBML that contains layout data — not just SBML produced by SBMLLayout.
* Bezier curves: SpeciesReferenceGlyph curve data is read if present.
  If absent (e.g. SBML produced by SBMLLayout 0.2.x), straight lines are drawn
  between the species node center and the reaction junction.
* Arrow heads are drawn as filled triangles at the product arm endpoints.
* The canvas is auto-sized to fit all glyphs with a configurable margin.
"""

from __future__ import annotations

import math
import os
from typing import Optional

import libsbml
import skia


# ---------------------------------------------------------------------------
# Default visual style
# ---------------------------------------------------------------------------

class Style:
    """
    Visual style parameters. Pass a customised instance to Renderer.draw().
    All colours are (R, G, B, A) tuples with values 0-255.
    """
    # Canvas
    background_color    = (255, 255, 255, 255)
    margin              = 30.0

    # Compartments
    compartment_fill    = (240, 240, 255, 180)
    compartment_stroke  = (100, 100, 200, 255)
    compartment_stroke_width = 2.0
    compartment_corner_radius = 8.0

    # Species nodes
    node_fill           = (250, 213, 211, 255)   # light salmon
    node_stroke         = (130, 37,  31,  255)   # dark red
    node_stroke_width   = 2.0
    node_corner_radius  = 6.0

    # Labels
    label_color         = (0, 0, 0, 255)
    label_font_size     = 14.0
    label_font_family   = 'Arial'

    # Reaction lines
    reaction_stroke     = (0, 0, 0, 255)
    reaction_stroke_width = 2.0

    # Junction circle
    junction_fill       = (130, 37, 31, 255)
    junction_radius     = 5.0
    junction_visible    = True

    # Gap between node edge and start/end of reaction lines (both ends)
    node_gap            = 6.0

    # Arrowhead
    arrow_fill          = (0, 0, 0, 255)
    arrow_length        = 12.0
    arrow_width         = 7.0


# ---------------------------------------------------------------------------
# Renderer
# ---------------------------------------------------------------------------

class Renderer:
    """
    Renders an SBML model that contains Layout extension data.

    Parameters
    ----------
    sbml_string : str
        SBML Level 3 string with the Layout package included.
        Use ``SBMLLayout.Layout(...).toSBML()`` to produce this.
    """

    def __init__(self, sbml_string: str):
        doc = libsbml.readSBMLFromString(sbml_string)
        if doc.getNumErrors(libsbml.LIBSBML_SEV_FATAL) > 0:
            raise ValueError(
                f"Invalid SBML: {doc.getError(0).getMessage()}")
        self._model = doc.getModel()
        if self._model is None:
            raise ValueError("SBML string contains no model.")
        mplugin = self._model.getPlugin('layout')
        if mplugin is None or mplugin.getNumLayouts() == 0:
            raise ValueError(
                "SBML contains no Layout extension data. "
                "Call SBMLLayout.Layout(...).toSBML() first.")
        self._layout = mplugin.getLayout(0)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def draw(self,
             output: Optional[str] = None,
             scale: float = 1.0,
             style: Optional[Style] = None) -> None:
        """
        Render the network diagram.

        Parameters
        ----------
        output : str, optional
            File path to write. Extension determines format:
            ``.png``, ``.pdf``, or ``.svg``.
            If None, attempts to display inline (Jupyter / IPython).
        scale : float
            Scale factor for raster output (PNG). Default 1.0.
            Use 2.0 for high-DPI / retina output.
        style : Style, optional
            Visual style overrides. If None, uses default Style().
        """
        if style is None:
            style = Style()

        bounds = self._compute_bounds(style)
        width  = int((bounds[2] - bounds[0] + 2 * style.margin) * scale)
        height = int((bounds[3] - bounds[1] + 2 * style.margin) * scale)
        ox = -bounds[0] + style.margin   # offset to shift into canvas
        oy = -bounds[1] + style.margin

        fmt = _output_format(output)

        if fmt == 'pdf':
            stream   = skia.DynamicMemoryWStream()
            document = skia.PDF.MakeDocument(stream)
            canvas   = document.beginPage(width, height)
            self._render(canvas, ox, oy, scale, style)
            document.endPage()
            document.close()
            with open(output, 'wb') as f:
                f.write(stream.detachAsData().bytes())

        elif fmt == 'svg':
            stream  = skia.DynamicMemoryWStream()
            svgcanvas = skia.SVGCanvas.Make(
                (width, height), stream)
            self._render(svgcanvas, ox, oy, scale, style)
            del svgcanvas   # flushes
            svg_data = stream.detachAsData().bytes()
            with open(output, 'wb') as f:
                f.write(svg_data)

        else:
            # PNG or inline display
            surface = skia.Surface(width, height)
            with surface as canvas:
                self._render(canvas, ox, oy, scale, style)
            image = surface.makeImageSnapshot()
            if output:
                image.save(output, skia.kPNG)
            else:
                self._display_inline(image)

    # ------------------------------------------------------------------
    # Rendering pipeline
    # ------------------------------------------------------------------

    def _render(self, canvas, ox: float, oy: float,
                scale: float, style: Style):
        """Draw everything onto the canvas."""
        # Background
        canvas.clear(skia.Color(*style.background_color))

        def tx(x): return (x + ox) * scale
        def ty(y): return (y + oy) * scale
        def ts(s): return s * scale

        # Draw in z-order: compartments → nodes → reactions
        self._draw_compartments(canvas, tx, ty, ts, style)
        self._draw_species_nodes(canvas, tx, ty, ts, style)
        self._draw_reactions(canvas, tx, ty, ts, style, scale)

    def _draw_compartments(self, canvas, tx, ty, ts, style: Style):
        lyt = self._layout
        for i in range(lyt.getNumCompartmentGlyphs()):
            cg = lyt.getCompartmentGlyph(i)
            bb = cg.getBoundingBox()
            rect = skia.RRect.MakeRectXY(
                skia.Rect.MakeXYWH(
                    tx(bb.getX()), ty(bb.getY()),
                    ts(bb.getWidth()), ts(bb.getHeight())),
                ts(style.compartment_corner_radius),
                ts(style.compartment_corner_radius))

            # Fill
            paint = skia.Paint(AntiAlias=True)
            paint.setColor(skia.Color(*style.compartment_fill))
            canvas.drawRRect(rect, paint)

            # Stroke
            paint.setStyle(skia.Paint.kStroke_Style)
            paint.setColor(skia.Color(*style.compartment_stroke))
            paint.setStrokeWidth(ts(style.compartment_stroke_width))
            canvas.drawRRect(rect, paint)

    def _draw_species_nodes(self, canvas, tx, ty, ts, style: Style):
        lyt = self._layout
        for i in range(lyt.getNumSpeciesGlyphs()):
            sg = lyt.getSpeciesGlyph(i)
            bb = sg.getBoundingBox()
            x  = tx(bb.getX());   y = ty(bb.getY())
            w  = ts(bb.getWidth()); h = ts(bb.getHeight())

            rect = skia.RRect.MakeRectXY(
                skia.Rect.MakeXYWH(x, y, w, h),
                ts(style.node_corner_radius),
                ts(style.node_corner_radius))

            # Fill
            paint = skia.Paint(AntiAlias=True)
            paint.setColor(skia.Color(*style.node_fill))
            canvas.drawRRect(rect, paint)

            # Stroke
            paint.setStyle(skia.Paint.kStroke_Style)
            paint.setColor(skia.Color(*style.node_stroke))
            paint.setStrokeWidth(ts(style.node_stroke_width))
            canvas.drawRRect(rect, paint)

            # Label — use species name if available, fall back to id
            species_id = sg.getSpeciesId()
            sp = self._model.getSpecies(species_id)
            label = (sp.getName() if sp and sp.getName() else species_id)
            self._draw_label(canvas, label,
                             x + w / 2, y + h / 2,
                             ts(style.label_font_size), style)

    def _draw_reactions(self, canvas, tx, ty, ts, style: Style, scale: float = 1.0):
        lyt = self._layout

        # Build lookups: glyph_id -> center and bounding box (in canvas coords)
        glyph_centers: dict[str, tuple[float, float]] = {}
        glyph_rects:   dict[str, tuple[float, float, float, float]] = {}
        for i in range(lyt.getNumSpeciesGlyphs()):
            sg = lyt.getSpeciesGlyph(i)
            bb = sg.getBoundingBox()
            gx = tx(bb.getX()); gy = ty(bb.getY())
            gw = ts(bb.getWidth()); gh = ts(bb.getHeight())
            glyph_centers[sg.getId()] = (gx + gw / 2, gy + gh / 2)
            glyph_rects[sg.getId()]   = (gx, gy, gw, gh)

        for i in range(lyt.getNumReactionGlyphs()):
            rg = lyt.getReactionGlyph(i)
            bb = rg.getBoundingBox()

            # Junction position from the ReactionGlyph bounding box
            jx = tx(bb.getX())
            jy = ty(bb.getY())

            # Detect direct reactions via the width=-1 sentinel set by layout.py.
            # Using position (0,0) as a sentinel is unreliable because a valid
            # junction can legitimately sit at or near the canvas origin.
            is_direct = (bb.getWidth() == -1)

            if is_direct:
                # Find reactant and product glyphs
                r_glyph_id = ''; p_glyph_id = ''
                r_curve = None
                for j in range(rg.getNumSpeciesReferenceGlyphs()):
                    srg = rg.getSpeciesReferenceGlyph(j)
                    role = srg.getRole()
                    if role == libsbml.SPECIES_ROLE_SUBSTRATE:
                        r_glyph_id = srg.getSpeciesGlyphId()
                        r_curve = srg.getCurve()
                    elif role == libsbml.SPECIES_ROLE_PRODUCT:
                        p_glyph_id = srg.getSpeciesGlyphId()

                if r_glyph_id in glyph_centers and p_glyph_id in glyph_centers:
                    if r_curve and r_curve.getNumCurveSegments() > 0:
                        # Bezier direct reaction
                        self._draw_direct_curve(canvas, r_curve, tx, ty, ts,
                                                style, r_glyph_id, p_glyph_id,
                                                glyph_rects, scale)
                    else:
                        # Straight direct line, clipped at expanded node edges
                        # (node + GAP) so lines start/end with a small gap.
                        GAP = ts(style.node_gap)
                        rx, ry = glyph_centers[r_glyph_id]
                        px, py = glyph_centers[p_glyph_id]
                        r_rect = glyph_rects.get(r_glyph_id)
                        p_rect = glyph_rects.get(p_glyph_id)
                        if r_rect:
                            ex, ey, ew, eh = r_rect
                            rx, ry = _rect_edge_point(
                                rx, ry, px, py,
                                ex - GAP, ey - GAP, ew + 2*GAP, eh + 2*GAP)
                        if p_rect:
                            ex, ey, ew, eh = p_rect
                            px, py = _rect_edge_point(
                                px, py, rx, ry,
                                ex - GAP, ey - GAP, ew + 2*GAP, eh + 2*GAP)
                        self._draw_straight_arm(canvas,
                            rx, ry, px, py, ts, style, arrow=True)
                continue  # skip junction-based drawing

            # --- Junction-based reaction ---
            for j in range(rg.getNumSpeciesReferenceGlyphs()):
                srg = rg.getSpeciesReferenceGlyph(j)
                glyph_id = srg.getSpeciesGlyphId()
                if glyph_id not in glyph_centers:
                    continue
                sx, sy = glyph_centers[glyph_id]
                role = srg.getRole()
                is_product = (role == libsbml.SPECIES_ROLE_PRODUCT or
                              role == libsbml.SPECIES_ROLE_SIDEPRODUCT)

                curve = srg.getCurve()
                if curve and curve.getNumCurveSegments() > 0:
                    self._draw_curve_arm(canvas, curve, tx, ty, ts,
                                         style, is_product,
                                         glyph_id, glyph_rects, scale)
                else:
                    rect = glyph_rects.get(glyph_id)
                    if rect:
                        GAP = ts(style.node_gap)
                        rx2, ry2, rw, rh = rect
                        ex, ey = _rect_edge_point(
                            sx, sy, jx, jy,
                            rx2 - GAP, ry2 - GAP, rw + 2*GAP, rh + 2*GAP)
                    else:
                        ex, ey = sx, sy
                    if is_product:
                        self._draw_straight_arm(canvas,
                            jx, jy, ex, ey, ts, style, arrow=True)
                    else:
                        self._draw_straight_arm(canvas,
                            ex, ey, jx, jy, ts, style, arrow=False)

            # Junction circle for multi-arm reactions
            n_arms = rg.getNumSpeciesReferenceGlyphs()
            is_junction_reaction = n_arms > 1 or (
                sum(1 for k in range(n_arms)
                    if rg.getSpeciesReferenceGlyph(k).getRole()
                       == libsbml.SPECIES_ROLE_SUBSTRATE) > 1 or
                sum(1 for k in range(n_arms)
                    if rg.getSpeciesReferenceGlyph(k).getRole()
                       == libsbml.SPECIES_ROLE_PRODUCT) > 1)
            if style.junction_visible and is_junction_reaction:
                paint = skia.Paint(AntiAlias=True)
                paint.setColor(skia.Color(*style.junction_fill))
                canvas.drawCircle(jx, jy, ts(style.junction_radius), paint)

    def _draw_direct_curve(self, canvas, curve, tx, ty, ts,
                            style: Style,
                            r_glyph_id: str, p_glyph_id: str,
                            glyph_rects: dict, scale: float):
        """Draw a direct bezier (no junction) from reactant to product.
        The curve is stored from reactant centre to product centre.
        We clip both ends at the node boundary."""
        paint = skia.Paint(
            AntiAlias=True,
            Style=skia.Paint.kStroke_Style,
            Color=skia.Color(*style.reaction_stroke),
            StrokeWidth=ts(style.reaction_stroke_width),
            StrokeCap=skia.Paint.kRound_Cap)

        for k in range(curve.getNumCurveSegments()):
            seg = curve.getCurveSegment(k)
            sx = tx(seg.getStart().getXOffset())
            sy = ty(seg.getStart().getYOffset())
            ex = tx(seg.getEnd().getXOffset())
            ey = ty(seg.getEnd().getYOffset())

            if seg.getTypeCode() == libsbml.SBML_LAYOUT_CUBICBEZIER:
                cb = seg
                bp1x = tx(cb.getBasePoint1().getXOffset())
                bp1y = ty(cb.getBasePoint1().getYOffset())
                bp2x = tx(cb.getBasePoint2().getXOffset())
                bp2y = ty(cb.getBasePoint2().getYOffset())

                # Clip start at reactant node edge
                r_rect = glyph_rects.get(r_glyph_id)
                if r_rect:
                    GAP = ts(style.node_gap)
                    rx2, ry2, rw, rh = r_rect
                    t_start = _bezier_clip_t(sx, sy, bp1x, bp1y,
                                             bp2x, bp2y, ex, ey,
                                             rx2 - GAP, ry2 - GAP,
                                             rw + 2*GAP, rh + 2*GAP)
                    right = _split_cubic_at_t(sx, sy, bp1x, bp1y,
                                              bp2x, bp2y, ex, ey, t_start)[1]
                    sx, sy, bp1x, bp1y, bp2x, bp2y, ex, ey = right

                # Clip end at product node edge
                p_rect = glyph_rects.get(p_glyph_id)
                if p_rect:
                    GAP = ts(style.node_gap)
                    px2, py2, pw, ph = p_rect
                    t_end = _bezier_clip_t(ex, ey, bp2x, bp2y,
                                           bp1x, bp1y, sx, sy,
                                           px2 - GAP, py2 - GAP,
                                           pw + 2*GAP, ph + 2*GAP)
                    t_clip = 1.0 - t_end
                    left = _split_cubic_at_t(sx, sy, bp1x, bp1y,
                                             bp2x, bp2y, ex, ey, t_clip)[0]
                    sx, sy, bp1x, bp1y, bp2x, bp2y, ex, ey = left

                # Shorten end slightly for arrowhead
                ex2, ey2 = _shorten_line(sx, sy, ex, ey,
                                         ts(style.arrow_length) * 0.8)
                path = skia.Path()
                path.moveTo(sx, sy)
                path.cubicTo(bp1x, bp1y, bp2x, bp2y, ex2, ey2)
                canvas.drawPath(path, paint)
                self._draw_arrowhead(canvas, bp2x, bp2y, ex, ey, ts, style)
            else:
                ex2, ey2 = _shorten_line(sx, sy, ex, ey,
                                         ts(style.arrow_length) * 0.8)
                canvas.drawLine(sx, sy, ex2, ey2, paint)
                self._draw_arrowhead(canvas, sx, sy, ex, ey, ts, style)

    def _draw_straight_arm(self, canvas,
                            x1: float, y1: float,
                            x2: float, y2: float,
                            ts, style: Style,
                            arrow: bool):
        """Draw a straight reaction arm, optionally with an arrowhead."""
        paint = skia.Paint(
            AntiAlias=True,
            Style=skia.Paint.kStroke_Style,
            Color=skia.Color(*style.reaction_stroke),
            StrokeWidth=ts(style.reaction_stroke_width))

        if arrow:
            # Arrowhead tip lands at (x2,y2) — the node edge/gap point.
            # Shorten the line so it ends at the arrowhead base, not the tip.
            tip_x, tip_y = x2, y2
            x2, y2 = _shorten_line(x1, y1, x2, y2,
                                    ts(style.arrow_length) * 0.8)
            canvas.drawLine(x1, y1, x2, y2, paint)
            self._draw_arrowhead(canvas, x1, y1, tip_x, tip_y, ts, style)
        else:
            canvas.drawLine(x1, y1, x2, y2, paint)

    def _draw_curve_arm(self, canvas, curve, tx, ty, ts,
                         style: Style, is_product: bool,
                         glyph_id: str = '', glyph_rects: dict = None,
                         scale: float = 1.0):
        """Draw a bezier reaction arm from SBML curve segment data."""
        paint = skia.Paint(
            AntiAlias=True,
            Style=skia.Paint.kStroke_Style,
            Color=skia.Color(*style.reaction_stroke),
            StrokeWidth=ts(style.reaction_stroke_width),
            StrokeCap=skia.Paint.kRound_Cap)

        for k in range(curve.getNumCurveSegments()):
            seg = curve.getCurveSegment(k)
            sx = tx(seg.getStart().getXOffset())
            sy = ty(seg.getStart().getYOffset())
            ex = tx(seg.getEnd().getXOffset())
            ey = ty(seg.getEnd().getYOffset())

            if seg.getTypeCode() == libsbml.SBML_LAYOUT_CUBICBEZIER:
                cb = seg
                bp1x = tx(cb.getBasePoint1().getXOffset())
                bp1y = ty(cb.getBasePoint1().getYOffset())
                bp2x = tx(cb.getBasePoint2().getXOffset())
                bp2y = ty(cb.getBasePoint2().getYOffset())

                if not is_product:
                    # Reactant arm: the bezier starts at the node centre.
                    # Clip to the expanded bounding box so the visible curve
                    # starts at the node edge and points naturally inward.
                    rect = glyph_rects.get(glyph_id)
                    if rect:
                        GAP = ts(style.node_gap)
                        rx, ry, rw, rh = rect
                        clip_x, clip_y = _rect_edge_point(
                            sx, sy, bp1x, bp1y,
                            rx - GAP, ry - GAP, rw + 2*GAP, rh + 2*GAP)
                        # Re-parameterise: find t where bezier exits the box,
                        # then draw only from that t onward using de Casteljau.
                        t = _bezier_clip_t(sx, sy, bp1x, bp1y,
                                           bp2x, bp2y, ex, ey,
                                           rx - GAP, ry - GAP,
                                           rw + 2*GAP, rh + 2*GAP)
                        right = _split_cubic_at_t(sx, sy, bp1x, bp1y,
                                                      bp2x, bp2y, ex, ey, t)[1]
                        sx, sy, bp1x, bp1y, bp2x, bp2y, _ex, _ey = right

                if is_product:
                    # Product arm: the bezier ends at the node centre.
                    # Clip using de Casteljau so the visible curve ends at the
                    # node edge and the arrowhead sits cleanly on the boundary.
                    rect = glyph_rects.get(glyph_id) if glyph_rects else None
                    if rect:
                        GAP = ts(style.node_gap)
                        rx, ry, rw, rh = rect
                        # Find t where the curve enters the expanded box
                        # (travelling from junction toward node centre).
                        # We want the LEFT segment i.e. up to that t.
                        t = _bezier_clip_t(ex, ey, bp2x, bp2y,
                                           bp1x, bp1y, sx, sy,
                                           rx - GAP, ry - GAP,
                                           rw + 2*GAP, rh + 2*GAP)
                        # t is measured from ex (node centre) toward sx (junction).
                        # We need 1-t measured from sx toward ex.
                        t_clip = 1.0 - t
                        left = _split_cubic_at_t(sx, sy, bp1x, bp1y,
                                                 bp2x, bp2y, ex, ey, t_clip)[0]
                        _sx, _sy, _bp1x, _bp1y, _bp2x, _bp2y, ex2, ey2 = left
                    else:
                        ex2, ey2 = _shorten_line(sx, sy, ex, ey,
                                                 ts(style.arrow_length) * 0.8)
                        _bp2x, _bp2y = bp2x, bp2y

                    # Use the split control points if we clipped, else originals
                    if rect:
                        draw_bp1x, draw_bp1y = _bp1x, _bp1y
                        draw_bp2x, draw_bp2y = _bp2x, _bp2y
                    else:
                        draw_bp1x, draw_bp1y = bp1x, bp1y
                        draw_bp2x, draw_bp2y = bp2x, bp2y

                    path = skia.Path()
                    path.moveTo(sx, sy)
                    path.cubicTo(draw_bp1x, draw_bp1y,
                                 draw_bp2x, draw_bp2y, ex2, ey2)
                    canvas.drawPath(path, paint)
                    # Arrowhead points from last control point toward node edge
                    self._draw_arrowhead(canvas,
                                         draw_bp2x, draw_bp2y, ex2, ey2,
                                         ts, style)
                else:
                    path = skia.Path()
                    path.moveTo(sx, sy)
                    path.cubicTo(bp1x, bp1y, bp2x, bp2y, ex, ey)
                    canvas.drawPath(path, paint)
            else:
                # Line segment
                if is_product:
                    ex2, ey2 = _shorten_line(sx, sy, ex, ey,
                                             ts(style.arrow_length) * 0.8)
                else:
                    ex2, ey2 = ex, ey
                canvas.drawLine(sx, sy, ex2, ey2, paint)
                if is_product:
                    self._draw_arrowhead(canvas, sx, sy, ex, ey, ts, style)

    def _draw_arrowhead(self, canvas,
                         x1: float, y1: float,
                         x2: float, y2: float,
                         ts, style: Style):
        """Draw a filled triangle arrowhead pointing toward (x2, y2)."""
        dx = x2 - x1
        dy = y2 - y1
        length = math.hypot(dx, dy)
        if length < 1e-6:
            return
        ux = dx / length   # unit vector along arm
        uy = dy / length
        px = -uy           # perpendicular
        py =  ux

        tip  = (x2, y2)
        base_center = (x2 - ux * ts(style.arrow_length),
                        y2 - uy * ts(style.arrow_length))
        half = ts(style.arrow_width) / 2
        left  = (base_center[0] + px * half, base_center[1] + py * half)
        right = (base_center[0] - px * half, base_center[1] - py * half)

        path = skia.Path()
        path.moveTo(*tip)
        path.lineTo(*left)
        path.lineTo(*right)
        path.close()

        paint = skia.Paint(AntiAlias=True,
                           Color=skia.Color(*style.arrow_fill))
        canvas.drawPath(path, paint)

    def _draw_label(self, canvas, text: str,
                     cx: float, cy: float,
                     font_size: float, style: Style):
        """Draw text centred at (cx, cy)."""
        font = skia.Font(skia.Typeface(style.label_font_family), font_size)
        blob = skia.TextBlob(text, font)
        bounds = font.measureText(text)
        paint = skia.Paint(AntiAlias=True,
                           Color=skia.Color(*style.label_color))
        # measureText returns width; estimate height from font metrics
        metrics = font.getMetrics()
        text_h = metrics.fDescent - metrics.fAscent
        canvas.drawTextBlob(blob,
                             cx - bounds / 2,
                             cy + text_h / 2 - metrics.fDescent,
                             paint)

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _compute_bounds(self, style: Style) -> tuple[float, float, float, float]:
        """Return (min_x, min_y, max_x, max_y) over all glyphs."""
        lyt = self._layout
        min_x = min_y =  1e18
        max_x = max_y = -1e18

        def expand(bb):
            nonlocal min_x, min_y, max_x, max_y
            min_x = min(min_x, bb.getX())
            min_y = min(min_y, bb.getY())
            max_x = max(max_x, bb.getX() + bb.getWidth())
            max_y = max(max_y, bb.getY() + bb.getHeight())

        for i in range(lyt.getNumCompartmentGlyphs()):
            expand(lyt.getCompartmentGlyph(i).getBoundingBox())
        for i in range(lyt.getNumSpeciesGlyphs()):
            expand(lyt.getSpeciesGlyph(i).getBoundingBox())
        for i in range(lyt.getNumReactionGlyphs()):
            bb = lyt.getReactionGlyph(i).getBoundingBox()
            # Skip direct reaction glyphs (width=-1 sentinel) and zero-size
            # junction markers — only species and compartment glyphs determine
            # the canvas bounds.
            if bb.getWidth() > 0:
                expand(bb)

        if min_x > 1e17:   # nothing found
            min_x = min_y = 0; max_x = max_y = 400

        return min_x, min_y, max_x, max_y

    def _display_inline(self, image):
        """Display a skia image inline in Jupyter / IPython."""
        try:
            from IPython.display import display, Image as IPImage
            import io
            data = image.encodeToData(skia.kPNG, 100)
            display(IPImage(data=bytes(data)))
        except ImportError:
            # Not in a notebook — save to a temp file and open it
            import tempfile, subprocess, sys
            tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
            image.save(tmp.name, skia.kPNG)
            if sys.platform == 'win32':
                os.startfile(tmp.name)
            elif sys.platform == 'darwin':
                subprocess.call(['open', tmp.name])
            else:
                subprocess.call(['xdg-open', tmp.name])


# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------

def _output_format(path: Optional[str]) -> str:
    """Return 'png', 'pdf', 'svg', or 'inline' from a file path."""
    if path is None:
        return 'inline'
    ext = os.path.splitext(path)[1].lower()
    if ext == '.pdf':
        return 'pdf'
    if ext == '.svg':
        return 'svg'
    return 'png'


def _shorten_line(x1: float, y1: float,
                   x2: float, y2: float,
                   amount: float) -> tuple[float, float]:
    """Return point ``amount`` pixels before (x2, y2) along the line."""
    dx = x2 - x1; dy = y2 - y1
    length = math.hypot(dx, dy)
    if length < 1e-6:
        return x2, y2
    t = max(0.0, (length - amount) / length)
    return x1 + dx * t, y1 + dy * t


def _bezier_clip_t(sx, sy, bp1x, bp1y, bp2x, bp2y, ex, ey,
                    rx, ry, rw, rh,
                    steps: int = 32) -> float:
    """
    Binary search for the smallest t in [0,1] where the cubic bezier
    (sx,sy)-(bp1x,bp1y)-(bp2x,bp2y)-(ex,ey) exits rectangle (rx,ry,rw,rh).
    Returns t=0 if start is already outside, t=1 if never exits.
    """
    def inside(t):
        # De Casteljau evaluation
        mt = 1 - t
        x = mt**3*sx + 3*mt**2*t*bp1x + 3*mt*t**2*bp2x + t**3*ex
        y = mt**3*sy + 3*mt**2*t*bp1y + 3*mt*t**2*bp2y + t**3*ey
        return rx <= x <= rx+rw and ry <= y <= ry+rh

    if not inside(0):
        return 0.0
    lo, hi = 0.0, 1.0
    for _ in range(steps):
        mid = (lo + hi) / 2
        if inside(mid):
            lo = mid
        else:
            hi = mid
    return hi


def _split_cubic_at_t(sx, sy, bp1x, bp1y, bp2x, bp2y, ex, ey, t):
    """
    Split a cubic bezier at parameter t using de Casteljau.
    Returns (left_segment, right_segment) each as
    (sx, sy, bp1x, bp1y, bp2x, bp2y, ex, ey).
    """
    mt = 1 - t
    p01x = mt*sx  + t*bp1x;  p01y = mt*sy  + t*bp1y
    p12x = mt*bp1x + t*bp2x; p12y = mt*bp1y + t*bp2y
    p23x = mt*bp2x + t*ex;   p23y = mt*bp2y + t*ey
    p012x = mt*p01x + t*p12x; p012y = mt*p01y + t*p12y
    p123x = mt*p12x + t*p23x; p123y = mt*p12y + t*p23y
    p0123x = mt*p012x + t*p123x; p0123y = mt*p012y + t*p123y
    left  = (sx, sy, p01x, p01y, p012x, p012y, p0123x, p0123y)
    right = (p0123x, p0123y, p123x, p123y, p23x, p23y, ex, ey)
    return left, right


def _rect_edge_point(cx: float, cy: float,
                     toward_x: float, toward_y: float,
                     rx: float, ry: float,
                     rw: float, rh: float) -> tuple[float, float]:
    """
    Return the point on the edge of rectangle (rx,ry,rw,rh) that lies on
    the line from its centre (cx,cy) toward (toward_x, toward_y).
    Used to start/end reaction arms at the node boundary rather than centre.
    """
    dx = toward_x - cx
    dy = toward_y - cy
    if abs(dx) < 1e-9 and abs(dy) < 1e-9:
        return cx, cy

    half_w = rw / 2
    half_h = rh / 2

    # Candidate t values for each of the four edges
    candidates = []
    if abs(dx) > 1e-9:
        for edge_x in (rx, rx + rw):
            t = (edge_x - cx) / dx
            if t > 1e-9:
                y = cy + t * dy
                if ry <= y <= ry + rh:
                    candidates.append(t)
    if abs(dy) > 1e-9:
        for edge_y in (ry, ry + rh):
            t = (edge_y - cy) / dy
            if t > 1e-9:
                x = cx + t * dx
                if rx <= x <= rx + rw:
                    candidates.append(t)

    if not candidates:
        return cx, cy

    t_min = min(candidates)
    return cx + t_min * dx, cy + t_min * dy