package d2target

import (
	"bytes"
	"encoding/json"
	"fmt"
	"hash/fnv"
	"math"
	"net/url"
	"strings"

	"github.com/d2lang/util-go/go2"

	"github.com/d2lang/d2/d2renderers/d2fonts"
	"github.com/d2lang/d2/lib/color"
	"github.com/d2lang/d2/lib/geo"
	"github.com/d2lang/d2/lib/label"
	"github.com/d2lang/d2/lib/shape"
	"github.com/d2lang/d2/lib/svg"
	"github.com/d2lang/d2/lib/textmeasure"
)

const (
	DEFAULT_ICON_SIZE = 32
	MAX_ICON_SIZE     = 64

	SHADOW_SIZE_X    = 3
	SHADOW_SIZE_Y    = 5
	THREE_DEE_OFFSET = 15
	MULTIPLE_OFFSET  = 10

	INNER_BORDER_OFFSET = 5

	BG_COLOR = color.N7
	FG_COLOR = color.N1

	MIN_ARROWHEAD_STROKE_WIDTH = 2
	ARROWHEAD_PADDING          = 2.

	CONNECTION_ICON_LABEL_GAP = 8
)

var BorderOffset = geo.NewVector(5, 5)

type Config struct {
	Sketch             *bool           `json:"sketch"`
	ThemeID            *int64          `json:"themeID"`
	DarkThemeID        *int64          `json:"darkThemeID"`
	Pad                *int64          `json:"pad"`
	Center             *bool           `json:"center"`
	LayoutEngine       *string         `json:"layoutEngine"`
	ThemeOverrides     *ThemeOverrides `json:"themeOverrides,omitempty"`
	DarkThemeOverrides *ThemeOverrides `json:"darkThemeOverrides,omitempty"`
	// Data is a data structure for holding user-defined data
	// useful for plugins that allow users to configure within source code
	Data map[string]interface{} `json:"data,omitempty"`
}

type ThemeOverrides struct {
	N1  *string `json:"n1"`
	N2  *string `json:"n2"`
	N3  *string `json:"n3"`
	N4  *string `json:"n4"`
	N5  *string `json:"n5"`
	N6  *string `json:"n6"`
	N7  *string `json:"n7"`
	B1  *string `json:"b1"`
	B2  *string `json:"b2"`
	B3  *string `json:"b3"`
	B4  *string `json:"b4"`
	B5  *string `json:"b5"`
	B6  *string `json:"b6"`
	AA2 *string `json:"aa2"`
	AA4 *string `json:"aa4"`
	AA5 *string `json:"aa5"`
	AB4 *string `json:"ab4"`
	AB5 *string `json:"ab5"`
}

type Diagram struct {
	Name   string  `json:"name"`
	Config *Config `json:"config,omitempty"`
	// See docs on the same field in d2graph to understand what it means.
	IsFolderOnly   bool                `json:"isFolderOnly"`
	Description    string              `json:"description,omitempty"`
	FontFamily     *d2fonts.FontFamily `json:"fontFamily,omitempty"`
	MonoFontFamily *d2fonts.FontFamily `json:"monoFontFamily,omitempty"`

	Shapes      []Shape      `json:"shapes"`
	Connections []Connection `json:"connections"`

	Root   Shape   `json:"root"`
	Legend *Legend `json:"legend,omitempty"`
	// Maybe Icon can be used as a watermark in the root shape

	Layers    []*Diagram `json:"layers,omitempty"`
	Scenarios []*Diagram `json:"scenarios,omitempty"`
	Steps     []*Diagram `json:"steps,omitempty"`
}

type Legend struct {
	Label       string       `json:"label,omitempty"`
	Shapes      []Shape      `json:"shapes,omitempty"`
	Connections []Connection `json:"connections,omitempty"`
}

func (d *Diagram) GetBoard(boardPath []string) *Diagram {
	if len(boardPath) == 0 {
		return d
	}

	head := boardPath[0]

	if len(boardPath) == 1 && d.Name == head {
		return d
	}

	switch head {
	case "layers":
		if len(boardPath) < 2 {
			return nil
		}
		for _, b := range d.Layers {
			if b.Name == boardPath[1] {
				return b.GetBoard(boardPath[2:])
			}
		}
	case "scenarios":
		if len(boardPath) < 2 {
			return nil
		}
		for _, b := range d.Scenarios {
			if b.Name == boardPath[1] {
				return b.GetBoard(boardPath[2:])
			}
		}
	case "steps":
		if len(boardPath) < 2 {
			return nil
		}
		for _, b := range d.Steps {
			if b.Name == boardPath[1] {
				return b.GetBoard(boardPath[2:])
			}
		}
	}

	for _, b := range d.Layers {
		if b.Name == head {
			return b.GetBoard(boardPath[1:])
		}
	}
	for _, b := range d.Scenarios {
		if b.Name == head {
			return b.GetBoard(boardPath[1:])
		}
	}
	for _, b := range d.Steps {
		if b.Name == head {
			return b.GetBoard(boardPath[1:])
		}
	}
	return nil
}

func (diagram Diagram) Bytes() ([]byte, error) {
	b1, err := marshalHashJSON(diagram.Shapes)
	if err != nil {
		return nil, err
	}
	b2, err := marshalHashJSON(diagram.Connections)
	if err != nil {
		return nil, err
	}
	b3, err := marshalHashJSON(diagram.Root)
	if err != nil {
		return nil, err
	}
	base := append(append(b1, b2...), b3...)

	if diagram.Config != nil {
		b, err := json.Marshal(diagram.Config)
		if err != nil {
			return nil, err
		}
		base = append(base, b...)
	}

	for _, d := range diagram.Layers {
		slices, err := d.Bytes()
		if err != nil {
			return nil, err
		}
		base = append(base, slices...)
	}
	for _, d := range diagram.Scenarios {
		slices, err := d.Bytes()
		if err != nil {
			return nil, err
		}
		base = append(base, slices...)
	}
	for _, d := range diagram.Steps {
		slices, err := d.Bytes()
		if err != nil {
			return nil, err
		}
		base = append(base, slices...)
	}

	return base, nil
}

// json.Marshal follows the declaration order of struct fields. Go 1.26
// reorganized net/url.URL, which otherwise changes diagram hashes (and every
// SVG identifier derived from them) without changing the diagram. Keep URL
// values in the order used through Go 1.25 for stable hashes across toolchains.
// This is applied only to Shape, Connection, and Root JSON used by Bytes.
func marshalHashJSON(v any) ([]byte, error) {
	b, err := json.Marshal(v)
	if err != nil {
		return nil, err
	}
	return stabilizeHashURLs(b)
}

var legacyURLFieldOrder = [...]string{
	"Scheme",
	"Opaque",
	"User",
	"Host",
	"Path",
	"RawPath",
	"OmitHost",
	"ForceQuery",
	"RawQuery",
	"Fragment",
	"RawFragment",
}

func stabilizeHashURLs(b []byte) ([]byte, error) {
	dec := json.NewDecoder(bytes.NewReader(b))
	var out bytes.Buffer
	lastWrite := 0

	var walkValue func() error
	walkValue = func() error {
		token, err := dec.Token()
		if err != nil {
			return err
		}
		delim, ok := token.(json.Delim)
		if !ok {
			return nil
		}

		switch delim {
		case '{':
			for dec.More() {
				keyToken, err := dec.Token()
				if err != nil {
					return err
				}
				key, ok := keyToken.(string)
				if !ok {
					return fmt.Errorf("decode diagram hash JSON: object key has type %T", keyToken)
				}
				if key != "icon" {
					if err := walkValue(); err != nil {
						return err
					}
					continue
				}

				valueStart, err := hashJSONValueStart(b, int(dec.InputOffset()))
				if err != nil {
					return err
				}
				var raw json.RawMessage
				if err := dec.Decode(&raw); err != nil {
					return fmt.Errorf("decode icon URL for stable diagram hash: %w", err)
				}
				if bytes.Equal(raw, []byte("null")) {
					continue
				}

				stable, err := stableHashURL(raw)
				if err != nil {
					return err
				}
				valueEnd := int(dec.InputOffset())
				if valueEnd < valueStart || valueEnd > len(b) {
					return fmt.Errorf("decode icon URL for stable diagram hash: invalid offsets %d:%d", valueStart, valueEnd)
				}
				out.Write(b[lastWrite:valueStart])
				out.Write(stable)
				lastWrite = valueEnd
			}
			closeToken, err := dec.Token()
			if err != nil {
				return err
			}
			if closeToken != json.Delim('}') {
				return fmt.Errorf("decode diagram hash JSON: got closing token %v, want }", closeToken)
			}
		case '[':
			for dec.More() {
				if err := walkValue(); err != nil {
					return err
				}
			}
			closeToken, err := dec.Token()
			if err != nil {
				return err
			}
			if closeToken != json.Delim(']') {
				return fmt.Errorf("decode diagram hash JSON: got closing token %v, want ]", closeToken)
			}
		default:
			return fmt.Errorf("decode diagram hash JSON: unexpected delimiter %q", delim)
		}
		return nil
	}

	if err := walkValue(); err != nil {
		return nil, err
	}
	out.Write(b[lastWrite:])
	return out.Bytes(), nil
}

func hashJSONValueStart(b []byte, keyEnd int) (int, error) {
	i := keyEnd
	for i < len(b) && (b[i] == ' ' || b[i] == '\t' || b[i] == '\r' || b[i] == '\n') {
		i++
	}
	if i < len(b) && b[i] == ':' {
		i++
	}
	for i < len(b) && (b[i] == ' ' || b[i] == '\t' || b[i] == '\r' || b[i] == '\n') {
		i++
	}
	if i >= len(b) {
		return 0, fmt.Errorf("decode icon URL for stable diagram hash: missing value")
	}
	return i, nil
}

func stableHashURL(raw json.RawMessage) ([]byte, error) {
	fields := make(map[string]json.RawMessage, len(legacyURLFieldOrder))
	if err := json.Unmarshal(raw, &fields); err != nil {
		return nil, fmt.Errorf("decode icon URL for stable diagram hash: %w", err)
	}
	if len(fields) != len(legacyURLFieldOrder) {
		return nil, fmt.Errorf("decode icon URL for stable diagram hash: got %d fields, want %d", len(fields), len(legacyURLFieldOrder))
	}

	var out bytes.Buffer
	out.WriteByte('{')
	for i, name := range legacyURLFieldOrder {
		value, ok := fields[name]
		if !ok {
			return nil, fmt.Errorf("decode icon URL for stable diagram hash: missing field %q", name)
		}
		if i > 0 {
			out.WriteByte(',')
		}
		out.WriteByte('"')
		out.WriteString(name)
		out.WriteString(`":`)
		out.Write(value)
	}
	out.WriteByte('}')
	return out.Bytes(), nil
}

func (diagram Diagram) HasShape(condition func(Shape) bool) bool {
	for _, d := range diagram.Layers {
		if d.HasShape(condition) {
			return true
		}
	}
	for _, d := range diagram.Scenarios {
		if d.HasShape(condition) {
			return true
		}
	}
	for _, d := range diagram.Steps {
		if d.HasShape(condition) {
			return true
		}
	}
	for _, s := range diagram.Shapes {
		if condition(s) {
			return true
		}
	}
	return false
}

func (diagram Diagram) HashID(salt *string) (string, error) {
	bytes, err := diagram.Bytes()
	if err != nil {
		return "", err
	}
	h := fnv.New32a()
	h.Write(bytes)
	if salt != nil {
		h.Write([]byte(*salt))
	}
	// CSS names can't start with numbers, so prepend a little something
	return fmt.Sprintf("d2-%d", h.Sum32()), nil
}

func (diagram Diagram) NestedBoundingBox() (topLeft, bottomRight Point) {
	tl, br := diagram.BoundingBox()
	for _, d := range diagram.Layers {
		tl2, br2 := d.NestedBoundingBox()
		tl.X = go2.Min(tl.X, tl2.X)
		tl.Y = go2.Min(tl.Y, tl2.Y)
		br.X = go2.Max(br.X, br2.X)
		br.Y = go2.Max(br.Y, br2.Y)
	}
	for _, d := range diagram.Scenarios {
		tl2, br2 := d.NestedBoundingBox()
		tl.X = go2.Min(tl.X, tl2.X)
		tl.Y = go2.Min(tl.Y, tl2.Y)
		br.X = go2.Max(br.X, br2.X)
		br.Y = go2.Max(br.Y, br2.Y)
	}
	for _, d := range diagram.Steps {
		tl2, br2 := d.NestedBoundingBox()
		tl.X = go2.Min(tl.X, tl2.X)
		tl.Y = go2.Min(tl.Y, tl2.Y)
		br.X = go2.Max(br.X, br2.X)
		br.Y = go2.Max(br.Y, br2.Y)
	}
	return tl, br
}

func (diagram Diagram) BoundingBox() (topLeft, bottomRight Point) {
	if len(diagram.Shapes) == 0 {
		return Point{0, 0}, Point{0, 0}
	}
	x1 := int(math.MaxInt32)
	y1 := int(math.MaxInt32)
	x2 := int(math.MinInt32)
	y2 := int(math.MinInt32)

	for _, targetShape := range diagram.Shapes {
		x1 = go2.Min(x1, targetShape.Pos.X-int(math.Ceil(float64(targetShape.StrokeWidth)/2.)))
		y1 = go2.Min(y1, targetShape.Pos.Y-int(math.Ceil(float64(targetShape.StrokeWidth)/2.)))
		x2 = go2.Max(x2, targetShape.Pos.X+targetShape.Width+int(math.Ceil(float64(targetShape.StrokeWidth)/2.)))
		y2 = go2.Max(y2, targetShape.Pos.Y+targetShape.Height+int(math.Ceil(float64(targetShape.StrokeWidth)/2.)))

		if targetShape.Type == ShapeC4Person {
			headRadius := int(float64(targetShape.Width) * 0.22)
			headCenterY := int(float64(targetShape.Height) * 0.18)
			headTop := targetShape.Pos.Y + headCenterY - headRadius
			y1 = go2.Min(y1, headTop-targetShape.StrokeWidth)
		}

		if targetShape.Tooltip != "" || targetShape.Link != "" {
			if targetShape.TooltipPosition != "" {
				tooltipMinX, tooltipMinY, tooltipMaxX, tooltipMaxY := calculateTooltipBounds(targetShape)
				x1 = go2.Min(x1, tooltipMinX)
				y1 = go2.Min(y1, tooltipMinY)
				x2 = go2.Max(x2, tooltipMaxX)
				y2 = go2.Max(y2, tooltipMaxY)
			} else {
				y1 = go2.Min(y1, targetShape.Pos.Y-targetShape.StrokeWidth-16)
				x2 = go2.Max(x2, targetShape.Pos.X+targetShape.StrokeWidth+targetShape.Width+16)
			}
		}
		if targetShape.Shadow {
			y2 = go2.Max(y2, targetShape.Pos.Y+targetShape.Height+int(math.Ceil(float64(targetShape.StrokeWidth)/2.))+SHADOW_SIZE_Y)
			x2 = go2.Max(x2, targetShape.Pos.X+targetShape.Width+int(math.Ceil(float64(targetShape.StrokeWidth)/2.))+SHADOW_SIZE_X)
		}

		if targetShape.ThreeDee {
			offsetY := THREE_DEE_OFFSET
			if targetShape.Type == ShapeHexagon {
				offsetY /= 2
			}
			y1 = go2.Min(y1, targetShape.Pos.Y-offsetY-targetShape.StrokeWidth)
			x2 = go2.Max(x2, targetShape.Pos.X+THREE_DEE_OFFSET+targetShape.Width+targetShape.StrokeWidth)
		}
		if targetShape.Multiple {
			y1 = go2.Min(y1, targetShape.Pos.Y-MULTIPLE_OFFSET-targetShape.StrokeWidth)
			x2 = go2.Max(x2, targetShape.Pos.X+MULTIPLE_OFFSET+targetShape.Width+targetShape.StrokeWidth)
		}

		if targetShape.Icon != nil && label.FromString(targetShape.IconPosition).IsOutside() {
			contentBox := geo.NewBox(geo.NewPoint(0, 0), float64(targetShape.Width), float64(targetShape.Height))
			s := shape.NewShape(targetShape.Type, contentBox)
			size := GetIconSize(s.GetInnerBox(), targetShape.IconPosition)

			if strings.HasPrefix(targetShape.IconPosition, "OUTSIDE_TOP") {
				y1 = go2.Min(y1, targetShape.Pos.Y-label.PADDING-size)
			} else if strings.HasPrefix(targetShape.IconPosition, "OUTSIDE_BOTTOM") {
				y2 = go2.Max(y2, targetShape.Pos.Y+targetShape.Height+label.PADDING+size)
			} else if strings.HasPrefix(targetShape.IconPosition, "OUTSIDE_LEFT") {
				x1 = go2.Min(x1, targetShape.Pos.X-label.PADDING-size)
			} else if strings.HasPrefix(targetShape.IconPosition, "OUTSIDE_RIGHT") {
				x2 = go2.Max(x2, targetShape.Pos.X+targetShape.Width+label.PADDING+size)
			}
		}

		if targetShape.Label != "" {
			labelPosition := label.FromString(targetShape.LabelPosition)

			shapeType := DSL_SHAPE_TO_SHAPE_TYPE[targetShape.Type]
			s := shape.NewShape(shapeType,
				geo.NewBox(
					geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)),
					float64(targetShape.Width),
					float64(targetShape.Height),
				),
			)

			labelTL := labelPosition.GetPointOnBox(s.GetBox(), label.PADDING, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight))
			if targetShape.ThreeDee {
				offset := THREE_DEE_OFFSET
				if targetShape.Type == ShapeHexagon {
					offset /= 2
				}
				if strings.HasPrefix(targetShape.LabelPosition, "OUTSIDE_RIGHT") {
					labelTL.X += float64(offset)
				}
				if strings.HasPrefix(targetShape.LabelPosition, "OUTSIDE_TOP") {
					labelTL.Y -= float64(offset)
				}
			}
			x1 = go2.Min(x1, int(labelTL.X))
			y1 = go2.Min(y1, int(labelTL.Y))
			x2 = go2.Max(x2, int(labelTL.X)+targetShape.LabelWidth)
			y2 = go2.Max(y2, int(labelTL.Y)+targetShape.LabelHeight)
		}
	}

	for _, connection := range diagram.Connections {
		for _, point := range connection.Route {
			x1 = go2.Min(x1, int(math.Floor(point.X))-int(math.Ceil(float64(connection.StrokeWidth)/2.)))
			y1 = go2.Min(y1, int(math.Floor(point.Y))-int(math.Ceil(float64(connection.StrokeWidth)/2.)))
			x2 = go2.Max(x2, int(math.Ceil(point.X))+int(math.Ceil(float64(connection.StrokeWidth)/2.)))
			y2 = go2.Max(y2, int(math.Ceil(point.Y))+int(math.Ceil(float64(connection.StrokeWidth)/2.)))
		}

		if connection.Label != "" {
			labelTL := connection.GetLabelTopLeft()
			x1 = go2.Min(x1, int(labelTL.X))
			y1 = go2.Min(y1, int(labelTL.Y))
			x2 = go2.Max(x2, int(labelTL.X)+connection.LabelWidth)
			y2 = go2.Max(y2, int(labelTL.Y)+connection.LabelHeight)
		}
		if connection.SrcLabel != nil && connection.SrcLabel.Label != "" {
			labelTL := connection.GetArrowheadLabelPosition(false)
			x1 = go2.Min(x1, int(labelTL.X))
			y1 = go2.Min(y1, int(labelTL.Y))
			x2 = go2.Max(x2, int(labelTL.X)+connection.SrcLabel.LabelWidth)
			y2 = go2.Max(y2, int(labelTL.Y)+connection.SrcLabel.LabelHeight)
		}
		if connection.DstLabel != nil && connection.DstLabel.Label != "" {
			labelTL := connection.GetArrowheadLabelPosition(true)
			x1 = go2.Min(x1, int(labelTL.X))
			y1 = go2.Min(y1, int(labelTL.Y))
			x2 = go2.Max(x2, int(labelTL.X)+connection.DstLabel.LabelWidth)
			y2 = go2.Max(y2, int(labelTL.Y)+connection.DstLabel.LabelHeight)
		}
	}

	return Point{x1, y1}, Point{x2, y2}
}

func CalculateTooltipPosition(shapeX, shapeY, shapeWidth, shapeHeight float64, tooltipWidth, tooltipHeight int, position string) (x, y float64) {
	padding := 10.0

	switch position {
	case "top-left":
		x = shapeX
		y = shapeY - float64(tooltipHeight) - padding
	case "top-center":
		x = shapeX + shapeWidth/2 - float64(tooltipWidth)/2
		y = shapeY - float64(tooltipHeight) - padding
	case "top-right":
		x = shapeX + shapeWidth - float64(tooltipWidth)
		y = shapeY - float64(tooltipHeight) - padding
	case "center-left":
		x = shapeX - float64(tooltipWidth) - padding
		y = shapeY + shapeHeight/2 - float64(tooltipHeight)/2
	case "center-right":
		x = shapeX + shapeWidth + padding
		y = shapeY + shapeHeight/2 - float64(tooltipHeight)/2
	case "bottom-left":
		x = shapeX
		y = shapeY + shapeHeight + padding
	case "bottom-center":
		x = shapeX + shapeWidth/2 - float64(tooltipWidth)/2
		y = shapeY + shapeHeight + padding
	case "bottom-right":
		x = shapeX + shapeWidth - float64(tooltipWidth)
		y = shapeY + shapeHeight + padding
	default:
		x = shapeX + shapeWidth/2 - float64(tooltipWidth)/2
		y = shapeY - float64(tooltipHeight) - padding
	}

	return x, y
}

func calculateTooltipBounds(targetShape Shape) (minX, minY, maxX, maxY int) {
	if targetShape.Tooltip == "" || targetShape.TooltipPosition == "" {
		return 0, 0, 0, 0
	}

	var tooltipWidth, tooltipHeight int

	ruler, err := textmeasure.NewRuler()
	if err != nil {
		textLength := len(targetShape.Tooltip)
		tooltipWidth = go2.Min(textLength*8+20, 200)
		tooltipHeight = 30
	} else {
		var fontFamily *d2fonts.FontFamily
		if ff, exists := d2fonts.D2_FONT_TO_FAMILY[targetShape.FontFamily]; exists {
			fontFamily = &ff
		}

		width, height, err := textmeasure.MeasureMarkdown(targetShape.Tooltip, ruler, fontFamily, nil, targetShape.FontSize)
		if err != nil {
			textLength := len(targetShape.Tooltip)
			tooltipWidth = go2.Min(textLength*8+20, 200)
			tooltipHeight = 30
		} else {
			tooltipWidth = width + 20
			tooltipHeight = height + 20
		}
	}

	shapeX := float64(targetShape.Pos.X)
	shapeY := float64(targetShape.Pos.Y)
	shapeWidth := float64(targetShape.Width)
	shapeHeight := float64(targetShape.Height)

	x, y := CalculateTooltipPosition(shapeX, shapeY, shapeWidth, shapeHeight, tooltipWidth, tooltipHeight, targetShape.TooltipPosition)

	minX = int(math.Floor(x))
	minY = int(math.Floor(y))
	maxX = int(math.Ceil(x)) + tooltipWidth
	maxY = int(math.Ceil(y)) + tooltipHeight

	return minX, minY, maxX, maxY
}

func (diagram Diagram) GetNestedCorpus() string {
	corpus := diagram.GetCorpus()
	for _, d := range diagram.Layers {
		corpus += d.GetNestedCorpus()
	}
	for _, d := range diagram.Scenarios {
		corpus += d.GetNestedCorpus()
	}
	for _, d := range diagram.Steps {
		corpus += d.GetNestedCorpus()
	}

	return corpus
}

func (diagram Diagram) GetCorpus() string {
	var corpus string
	appendixCount := 0
	for _, s := range diagram.Shapes {
		corpus += s.Label
		if s.Tooltip != "" {
			corpus += s.Tooltip
			appendixCount++
			corpus += fmt.Sprint(appendixCount)
		}
		if s.Link != "" {
			corpus += s.Link
			appendixCount++
			corpus += fmt.Sprint(appendixCount)
		}
		corpus += s.PrettyLink
		if s.Type == ShapeClass {
			for _, cf := range s.Fields {
				corpus += cf.Text(0).Text + cf.VisibilityToken()
			}
			for _, cm := range s.Methods {
				corpus += cm.Text(0).Text + cm.VisibilityToken()
			}
		}
		if s.Type == ShapeSQLTable {
			for _, c := range s.Columns {
				for _, t := range c.Texts(0) {
					corpus += t.Text
				}
				corpus += c.ConstraintAbbr()
			}
		}
	}
	for _, c := range diagram.Connections {
		corpus += c.Label
		if c.SrcLabel != nil {
			corpus += c.SrcLabel.Label
		}
		if c.DstLabel != nil {
			corpus += c.DstLabel.Label
		}
	}

	if diagram.Legend != nil {
		if diagram.Legend.Label != "" {
			corpus += diagram.Legend.Label
		} else {
			corpus += "Legend"
		}
		for _, s := range diagram.Legend.Shapes {
			corpus += s.Label
		}
		for _, c := range diagram.Legend.Connections {
			corpus += c.Label
		}
	}

	return corpus
}

func NewDiagram() *Diagram {
	return &Diagram{
		Root: Shape{
			Fill: BG_COLOR,
		},
	}
}

type Shape struct {
	ID   string `json:"id"`
	Type string `json:"type"`

	Classes []string `json:"classes,omitempty"`

	Pos    Point `json:"pos"`
	Width  int   `json:"width"`
	Height int   `json:"height"`

	Opacity     float64 `json:"opacity"`
	StrokeDash  float64 `json:"strokeDash"`
	StrokeWidth int     `json:"strokeWidth"`

	BorderRadius int `json:"borderRadius"`

	Fill        string `json:"fill"`
	FillPattern string `json:"fillPattern,omitempty"`
	Stroke      string `json:"stroke"`

	Animated     bool `json:"animated"`
	Shadow       bool `json:"shadow"`
	ThreeDee     bool `json:"3d"`
	Multiple     bool `json:"multiple"`
	DoubleBorder bool `json:"double-border"`

	Tooltip          string   `json:"tooltip"`
	Link             string   `json:"link"`
	PrettyLink       string   `json:"prettyLink,omitempty"`
	Icon             *url.URL `json:"icon"`
	IconBorderRadius int      `json:"iconBorderRadius,omitempty"`
	IconPosition     string   `json:"iconPosition"`

	// Whether the shape should allow shapes behind it to bleed through
	// Currently just used for sequence diagram groups
	Blend bool `json:"blend"`

	Class
	SQLTable

	ContentAspectRatio *float64 `json:"contentAspectRatio,omitempty"`

	Text

	LabelPosition   string `json:"labelPosition,omitempty"`
	TooltipPosition string `json:"tooltipPosition,omitempty"`

	ZIndex int `json:"zIndex"`
	Level  int `json:"level"`

	// These are used for special shapes, sql_table and class
	PrimaryAccentColor   string `json:"primaryAccentColor,omitempty"`
	SecondaryAccentColor string `json:"secondaryAccentColor,omitempty"`
	NeutralAccentColor   string `json:"neutralAccentColor,omitempty"`
}

func (s Shape) GetFontColor() string {
	if s.Type == ShapeClass || s.Type == ShapeSQLTable {
		if !color.IsThemeColor(s.Color) {
			return s.Color
		}
		return s.Stroke
	}
	if s.Color != color.Empty {
		return s.Color
	}
	return color.N1
}

// TODO remove this function, just set fields on themeable
func (s Shape) CSSStyle() string {
	out := ""

	out += fmt.Sprintf(`stroke-width:%d;`, s.StrokeWidth)
	if s.StrokeDash != 0 {
		dashSize, gapSize := svg.GetStrokeDashAttributes(float64(s.StrokeWidth), s.StrokeDash)
		out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)
	}

	return out
}

func (s *Shape) SetType(t string) {
	// Some types are synonyms of other types, but with hinting for autolayout
	// They should only have one representation in the final export
	if strings.EqualFold(t, ShapeCircle) {
		t = ShapeOval
	} else if strings.EqualFold(t, ShapeSquare) {
		t = ShapeRectangle
	}
	s.Type = strings.ToLower(t)
}

func (s Shape) GetZIndex() int {
	return s.ZIndex
}

func (s Shape) GetID() string {
	return s.ID
}

type Text struct {
	Label      string `json:"label"`
	FontSize   int    `json:"fontSize"`
	FontFamily string `json:"fontFamily"`
	Language   string `json:"language"`
	Color      string `json:"color"`

	Italic    bool `json:"italic"`
	Bold      bool `json:"bold"`
	Underline bool `json:"underline"`

	LabelWidth  int    `json:"labelWidth"`
	LabelHeight int    `json:"labelHeight"`
	LabelFill   string `json:"labelFill,omitempty"`
}

func BaseShape() *Shape {
	return &Shape{
		Opacity:     1,
		StrokeDash:  0,
		StrokeWidth: 2,
		Text: Text{
			Bold:       true,
			FontFamily: "DEFAULT",
		},
	}
}

type Connection struct {
	ID string `json:"id"`

	Classes []string `json:"classes,omitempty"`

	Src      string    `json:"src"`
	SrcArrow Arrowhead `json:"srcArrow"`
	SrcLabel *Text     `json:"srcLabel,omitempty"`

	Dst      string    `json:"dst"`
	DstArrow Arrowhead `json:"dstArrow"`
	DstLabel *Text     `json:"dstLabel,omitempty"`

	Opacity      float64 `json:"opacity"`
	StrokeDash   float64 `json:"strokeDash"`
	StrokeWidth  int     `json:"strokeWidth"`
	Stroke       string  `json:"stroke"`
	Fill         string  `json:"fill,omitempty"`
	BorderRadius float64 `json:"borderRadius,omitempty"`

	Text
	LabelPosition   string  `json:"labelPosition"`
	LabelPercentage float64 `json:"labelPercentage"`

	Link       string `json:"link"`
	PrettyLink string `json:"prettyLink,omitempty"`

	Route   []*geo.Point `json:"route"`
	IsCurve bool         `json:"isCurve,omitempty"`

	Animated         bool     `json:"animated"`
	Tooltip          string   `json:"tooltip"`
	Icon             *url.URL `json:"icon"`
	IconPosition     string   `json:"iconPosition,omitempty"`
	IconBorderRadius float64  `json:"iconBorderRadius,omitempty"`

	ZIndex int `json:"zIndex"`
}

func (c *Connection) GetIconPosition() *geo.Point {
	if c.Icon == nil {
		return nil
	}

	if c.Label != "" {
		labelTL := c.GetLabelTopLeft()
		if labelTL != nil {
			// Position icon to the left of the label with a small gap
			return &geo.Point{
				X: labelTL.X - CONNECTION_ICON_LABEL_GAP - DEFAULT_ICON_SIZE,
				Y: labelTL.Y + float64(c.LabelHeight)/2 - DEFAULT_ICON_SIZE/2,
			}
		}
	}

	point, _ := label.FromString(c.IconPosition).GetPointOnRoute(
		c.Route,
		float64(c.StrokeWidth),
		-1,
		float64(DEFAULT_ICON_SIZE),
		float64(DEFAULT_ICON_SIZE),
	)
	return point
}

func BaseConnection() *Connection {
	return &Connection{
		SrcArrow:     NoArrowhead,
		DstArrow:     NoArrowhead,
		Route:        make([]*geo.Point, 0),
		Opacity:      1,
		StrokeDash:   0,
		StrokeWidth:  2,
		BorderRadius: 10,
		Text: Text{
			Italic:     true,
			FontFamily: "DEFAULT",
		},
	}
}

func (c Connection) GetFontColor() string {
	if c.Color != color.Empty {
		return c.Color
	}
	return color.N1
}

func (c Connection) CSSStyle() string {
	out := ""

	out += fmt.Sprintf(`stroke-width:%d;`, c.StrokeWidth)
	strokeDash := c.StrokeDash
	if strokeDash == 0 && c.Animated {
		strokeDash = 5
	}
	if strokeDash != 0 {
		dashSize, gapSize := svg.GetStrokeDashAttributes(float64(c.StrokeWidth), strokeDash)
		out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)

		if c.Animated {
			dashOffset := -10
			if c.SrcArrow != NoArrowhead && c.DstArrow == NoArrowhead {
				dashOffset = 10
			}
			out += fmt.Sprintf(`stroke-dashoffset:%f;`, float64(dashOffset)*(dashSize+gapSize))
			out += fmt.Sprintf(`animation: dashdraw %fs linear infinite;`, gapSize*0.5)
		}
	}
	return out
}

func (c *Connection) GetLabelTopLeft() *geo.Point {
	point, _ := label.FromString(c.LabelPosition).GetPointOnRoute(
		c.Route,
		float64(c.StrokeWidth),
		c.LabelPercentage,
		float64(c.LabelWidth),
		float64(c.LabelHeight),
	)
	return point
}

func (connection *Connection) GetArrowheadLabelPosition(isDst bool) *geo.Point {
	var width, height float64
	if isDst {
		width = float64(connection.DstLabel.LabelWidth)
		height = float64(connection.DstLabel.LabelHeight)
	} else {
		width = float64(connection.SrcLabel.LabelWidth)
		height = float64(connection.SrcLabel.LabelHeight)
	}

	// get the start/end points of edge segment with arrowhead
	index := 0
	if isDst {
		index = len(connection.Route) - 2
	}
	start, end := connection.Route[index], connection.Route[index+1]
	// Note: end to start to get normal towards unlocked top position
	normalX, normalY := geo.GetUnitNormalVector(end.X, end.Y, start.X, start.Y)

	// determine how much to move the label back from the very end of the edge
	// e.g. if normal points up {x: 0, y:1}, shift width/2 + padding to fit
	shift := math.Abs(normalX)*(height/2.+label.PADDING) +
		math.Abs(normalY)*(width/2.+label.PADDING)

	length := geo.Route(connection.Route).Length()
	var position float64
	if isDst {
		position = 1.
		if length > 0 {
			position -= shift / length
		}
	} else {
		position = 0.
		if length > 0 {
			position = shift / length
		}
	}

	strokeWidth := float64(connection.StrokeWidth)

	labelTL, _ := label.UnlockedTop.GetPointOnRoute(connection.Route, strokeWidth, position, width, height)

	var arrowSize float64
	if isDst && connection.DstArrow != NoArrowhead {
		// Note: these dimensions are for rendering arrowheads on their side so we want the height
		_, arrowSize = connection.DstArrow.Dimensions(strokeWidth)
	} else if connection.SrcArrow != NoArrowhead {
		_, arrowSize = connection.SrcArrow.Dimensions(strokeWidth)
	}

	if arrowSize > 0 {
		// labelTL already accounts for strokeWidth and padding, we only want to shift further if the arrow is larger than this
		offset := (arrowSize/2 + ARROWHEAD_PADDING) - strokeWidth/2 - label.PADDING
		if offset > 0 {
			labelTL.X += normalX * offset
			labelTL.Y += normalY * offset
		}
	}

	return labelTL
}

func (c Connection) GetZIndex() int {
	return c.ZIndex
}

func (c Connection) GetID() string {
	return c.ID
}

type Arrowhead string

const (
	NoArrowhead               Arrowhead = "none"
	ArrowArrowhead            Arrowhead = "arrow"
	UnfilledTriangleArrowhead Arrowhead = "unfilled-triangle"
	TriangleArrowhead         Arrowhead = "triangle"
	DiamondArrowhead          Arrowhead = "diamond"
	FilledDiamondArrowhead    Arrowhead = "filled-diamond"
	CircleArrowhead           Arrowhead = "circle"
	FilledCircleArrowhead     Arrowhead = "filled-circle"
	CrossArrowhead            Arrowhead = "cross"
	BoxArrowhead              Arrowhead = "box"
	FilledBoxArrowhead        Arrowhead = "filled-box"

	// For fat arrows
	LineArrowhead Arrowhead = "line"

	// Crows feet notation
	CfOne          Arrowhead = "cf-one"
	CfMany         Arrowhead = "cf-many"
	CfOneRequired  Arrowhead = "cf-one-required"
	CfManyRequired Arrowhead = "cf-many-required"

	DefaultArrowhead Arrowhead = TriangleArrowhead
)

// valid values for arrowhead.shape
var Arrowheads = map[string]struct{}{
	string(NoArrowhead):       {},
	string(ArrowArrowhead):    {},
	string(TriangleArrowhead): {},
	string(DiamondArrowhead):  {},
	string(CircleArrowhead):   {},
	string(BoxArrowhead):      {},
	string(CfOne):             {},
	string(CfMany):            {},
	string(CfOneRequired):     {},
	string(CfManyRequired):    {},
	string(CrossArrowhead):    {},
}

func ToArrowhead(arrowheadType string, filled *bool) Arrowhead {
	switch arrowheadType {
	case string(DiamondArrowhead):
		if filled != nil && *filled {
			return FilledDiamondArrowhead
		}
		return DiamondArrowhead
	case string(CircleArrowhead):
		if filled != nil && *filled {
			return FilledCircleArrowhead
		}
		return CircleArrowhead
	case string(NoArrowhead):
		return NoArrowhead
	case string(ArrowArrowhead):
		return ArrowArrowhead
	case string(TriangleArrowhead):
		if filled != nil && !(*filled) {
			return UnfilledTriangleArrowhead
		}
		return TriangleArrowhead
	case string(CrossArrowhead):
		return CrossArrowhead
	case string(BoxArrowhead):
		if filled != nil && *filled {
			return FilledBoxArrowhead
		}
		return BoxArrowhead
	case string(CfOne):
		return CfOne
	case string(CfMany):
		return CfMany
	case string(CfOneRequired):
		return CfOneRequired
	case string(CfManyRequired):
		return CfManyRequired
	default:
		if DefaultArrowhead == TriangleArrowhead &&
			filled != nil && !(*filled) {
			return UnfilledTriangleArrowhead
		}
		return DefaultArrowhead
	}
}

func (arrowhead Arrowhead) Dimensions(strokeWidth float64) (width, height float64) {
	var baseWidth, baseHeight float64
	var widthMultiplier, heightMultiplier float64
	switch arrowhead {
	case ArrowArrowhead:
		baseWidth = 4
		baseHeight = 4
		widthMultiplier = 4
		heightMultiplier = 4
	case TriangleArrowhead:
		baseWidth = 4
		baseHeight = 4
		widthMultiplier = 3
		heightMultiplier = 4
	case UnfilledTriangleArrowhead:
		baseWidth = 7
		baseHeight = 7
		widthMultiplier = 3
		heightMultiplier = 4
	case LineArrowhead:
		widthMultiplier = 5
		heightMultiplier = 8
	case FilledDiamondArrowhead:
		baseWidth = 11
		baseHeight = 7
		widthMultiplier = 5.5
		heightMultiplier = 3.5
	case DiamondArrowhead:
		baseWidth = 11
		baseHeight = 9
		widthMultiplier = 5.5
		heightMultiplier = 4.5
	case CrossArrowhead:
		baseWidth = 7
		baseHeight = 7
		widthMultiplier = 5
		heightMultiplier = 5
	case FilledCircleArrowhead, CircleArrowhead:
		baseWidth = 8
		baseHeight = 8
		widthMultiplier = 5
		heightMultiplier = 5
	case FilledBoxArrowhead, BoxArrowhead:
		baseWidth = 6
		baseHeight = 6
		widthMultiplier = 5
		heightMultiplier = 5
	case CfOne, CfMany, CfOneRequired, CfManyRequired:
		baseWidth = 9
		baseHeight = 9
		widthMultiplier = 4.5
		heightMultiplier = 4.5
	}

	clippedStrokeWidth := go2.Max(MIN_ARROWHEAD_STROKE_WIDTH, strokeWidth)
	return baseWidth + clippedStrokeWidth*widthMultiplier, baseHeight + clippedStrokeWidth*heightMultiplier
}

type Point struct {
	X int `json:"x"`
	Y int `json:"y"`
}

func NewPoint(x, y int) Point {
	return Point{X: x, Y: y}
}

const (
	ShapeRectangle       = "rectangle"
	ShapeSquare          = "square"
	ShapePage            = "page"
	ShapeParallelogram   = "parallelogram"
	ShapeDocument        = "document"
	ShapeCylinder        = "cylinder"
	ShapeQueue           = "queue"
	ShapePackage         = "package"
	ShapeStep            = "step"
	ShapeCallout         = "callout"
	ShapeStoredData      = "stored_data"
	ShapePerson          = "person"
	ShapeC4Person        = "c4-person"
	ShapeDiamond         = "diamond"
	ShapeOval            = "oval"
	ShapeCircle          = "circle"
	ShapeHexagon         = "hexagon"
	ShapeCloud           = "cloud"
	ShapeText            = "text"
	ShapeCode            = "code"
	ShapeClass           = "class"
	ShapeSQLTable        = "sql_table"
	ShapeImage           = "image"
	ShapeSequenceDiagram = "sequence_diagram"
	ShapeHierarchy       = "hierarchy"
)

var Shapes = []string{
	ShapeRectangle,
	ShapeSquare,
	ShapePage,
	ShapeParallelogram,
	ShapeDocument,
	ShapeCylinder,
	ShapeQueue,
	ShapePackage,
	ShapeStep,
	ShapeCallout,
	ShapeStoredData,
	ShapePerson,
	ShapeC4Person,
	ShapeDiamond,
	ShapeOval,
	ShapeCircle,
	ShapeHexagon,
	ShapeCloud,
	ShapeText,
	ShapeCode,
	ShapeClass,
	ShapeSQLTable,
	ShapeImage,
	ShapeSequenceDiagram,
	ShapeHierarchy,
}

func IsShape(s string) bool {
	if s == "" {
		// Default shape is rectangle.
		return true
	}
	for _, s2 := range Shapes {
		if strings.EqualFold(s, s2) {
			return true
		}
	}
	return false
}

type MText struct {
	Text        string `json:"text"`
	FontSize    int    `json:"fontSize"`
	IsBold      bool   `json:"isBold"`
	IsItalic    bool   `json:"isItalic"`
	IsUnderline bool   `json:"isUnderline"`
	Language    string `json:"language"`
	Shape       string `json:"shape"`

	Dimensions TextDimensions `json:"dimensions,omitempty"`
}

type TextDimensions struct {
	Width  int `json:"width"`
	Height int `json:"height"`
}

func NewTextDimensions(w, h int) *TextDimensions {
	return &TextDimensions{Width: w, Height: h}
}

func (text MText) GetColor(isItalic bool) string {
	if isItalic {
		return color.N2
	}
	return color.N1
}

var DSL_SHAPE_TO_SHAPE_TYPE = map[string]string{
	"":                   shape.SQUARE_TYPE,
	ShapeRectangle:       shape.SQUARE_TYPE,
	ShapeSquare:          shape.REAL_SQUARE_TYPE,
	ShapePage:            shape.PAGE_TYPE,
	ShapeParallelogram:   shape.PARALLELOGRAM_TYPE,
	ShapeDocument:        shape.DOCUMENT_TYPE,
	ShapeCylinder:        shape.CYLINDER_TYPE,
	ShapeQueue:           shape.QUEUE_TYPE,
	ShapePackage:         shape.PACKAGE_TYPE,
	ShapeStep:            shape.STEP_TYPE,
	ShapeCallout:         shape.CALLOUT_TYPE,
	ShapeStoredData:      shape.STORED_DATA_TYPE,
	ShapePerson:          shape.PERSON_TYPE,
	ShapeC4Person:        shape.C4_PERSON_TYPE,
	ShapeDiamond:         shape.DIAMOND_TYPE,
	ShapeOval:            shape.OVAL_TYPE,
	ShapeCircle:          shape.CIRCLE_TYPE,
	ShapeHexagon:         shape.HEXAGON_TYPE,
	ShapeCloud:           shape.CLOUD_TYPE,
	ShapeText:            shape.TEXT_TYPE,
	ShapeCode:            shape.CODE_TYPE,
	ShapeClass:           shape.CLASS_TYPE,
	ShapeSQLTable:        shape.TABLE_TYPE,
	ShapeImage:           shape.IMAGE_TYPE,
	ShapeSequenceDiagram: shape.SQUARE_TYPE,
	ShapeHierarchy:       shape.SQUARE_TYPE,
}

var SHAPE_TYPE_TO_DSL_SHAPE map[string]string

func init() {
	SHAPE_TYPE_TO_DSL_SHAPE = make(map[string]string, len(DSL_SHAPE_TO_SHAPE_TYPE))
	for k, v := range DSL_SHAPE_TO_SHAPE_TYPE {
		SHAPE_TYPE_TO_DSL_SHAPE[v] = k
	}
	// SQUARE_TYPE is defined twice in the map, make sure it doesn't get set to the empty string one
	SHAPE_TYPE_TO_DSL_SHAPE[shape.SQUARE_TYPE] = ShapeRectangle
}

func GetIconSize(box *geo.Box, position string) int {
	iconPosition := label.FromString(position)

	minDimension := int(math.Min(box.Width, box.Height))
	halfMinDimension := int(math.Ceil(0.5 * float64(minDimension)))

	var size int

	if iconPosition == label.InsideMiddleCenter {
		size = halfMinDimension
	} else {
		size = go2.Min(
			minDimension,
			go2.Max(DEFAULT_ICON_SIZE, halfMinDimension),
		)
	}

	size = go2.Min(size, MAX_ICON_SIZE)

	if !iconPosition.IsOutside() {
		size = go2.Min(size,
			go2.Min(
				go2.Max(int(box.Width)-2*label.PADDING, 0),
				go2.Max(int(box.Height)-2*label.PADDING, 0),
			),
		)
	}

	return size
}
