// d2elklayout is a wrapper around the Javascript port of ELK.
//
// Coordinates are relative to parents.
// See https://www.eclipse.org/elk/documentation/tooldevelopers/graphdatastructure/coordinatesystem.html
package d2elklayout

import (
	"context"
	_ "embed"
	"encoding/json"
	"errors"
	"fmt"
	"math"
	"regexp"
	"strconv"
	"strings"

	"github.com/dop251/goja"

	"oss.terrastruct.com/util-go/xdefer"

	"oss.terrastruct.com/util-go/go2"

	"oss.terrastruct.com/d2/d2graph"
	"oss.terrastruct.com/d2/d2target"
	"oss.terrastruct.com/d2/lib/geo"
	"oss.terrastruct.com/d2/lib/label"
	"oss.terrastruct.com/d2/lib/shape"
)

//go:embed elk.js
var elkJS string

//go:embed setup.js
var setupJS string

type ELKNode struct {
	ID            string      `json:"id"`
	X             float64     `json:"x"`
	Y             float64     `json:"y"`
	Width         float64     `json:"width"`
	Height        float64     `json:"height"`
	Children      []*ELKNode  `json:"children,omitempty"`
	Labels        []*ELKLabel `json:"labels,omitempty"`
	LayoutOptions *elkOpts    `json:"layoutOptions,omitempty"`
}

type ELKLabel struct {
	Text          string   `json:"text"`
	X             float64  `json:"x"`
	Y             float64  `json:"y"`
	Width         float64  `json:"width"`
	Height        float64  `json:"height"`
	LayoutOptions *elkOpts `json:"layoutOptions,omitempty"`
}

type ELKPoint struct {
	X float64 `json:"x"`
	Y float64 `json:"y"`
}

type ELKEdgeSection struct {
	Start      ELKPoint   `json:"startPoint"`
	End        ELKPoint   `json:"endPoint"`
	BendPoints []ELKPoint `json:"bendPoints,omitempty"`
}

type ELKEdge struct {
	ID        string           `json:"id"`
	Sources   []string         `json:"sources"`
	Targets   []string         `json:"targets"`
	Sections  []ELKEdgeSection `json:"sections,omitempty"`
	Labels    []*ELKLabel      `json:"labels,omitempty"`
	Container string           `json:"container"`
}

type ELKGraph struct {
	ID            string     `json:"id"`
	LayoutOptions *elkOpts   `json:"layoutOptions"`
	Children      []*ELKNode `json:"children,omitempty"`
	Edges         []*ELKEdge `json:"edges,omitempty"`
}

type ConfigurableOpts struct {
	Algorithm       string `json:"elk.algorithm,omitempty"`
	NodeSpacing     int    `json:"spacing.nodeNodeBetweenLayers,omitempty"`
	Padding         string `json:"elk.padding,omitempty"`
	EdgeNodeSpacing int    `json:"spacing.edgeNodeBetweenLayers,omitempty"`
	SelfLoopSpacing int    `json:"elk.spacing.nodeSelfLoop"`
}

var DefaultOpts = ConfigurableOpts{
	Algorithm:       "layered",
	NodeSpacing:     70.0,
	Padding:         "[top=50,left=50,bottom=50,right=50]",
	EdgeNodeSpacing: 40.0,
	SelfLoopSpacing: 50.0,
}

var port_spacing = 40.
var edge_node_spacing = 40

type elkOpts struct {
	EdgeNode                     int    `json:"elk.spacing.edgeNode,omitempty"`
	FixedAlignment               string `json:"elk.layered.nodePlacement.bk.fixedAlignment,omitempty"`
	Thoroughness                 int    `json:"elk.layered.thoroughness,omitempty"`
	EdgeEdgeBetweenLayersSpacing int    `json:"elk.layered.spacing.edgeEdgeBetweenLayers,omitempty"`
	Direction                    string `json:"elk.direction"`
	HierarchyHandling            string `json:"elk.hierarchyHandling,omitempty"`
	InlineEdgeLabels             bool   `json:"elk.edgeLabels.inline,omitempty"`
	ForceNodeModelOrder          bool   `json:"elk.layered.crossingMinimization.forceNodeModelOrder,omitempty"`
	ConsiderModelOrder           string `json:"elk.layered.considerModelOrder.strategy,omitempty"`
	CycleBreakingStrategy        string `json:"elk.layered.cycleBreaking.strategy,omitempty"`

	SelfLoopDistribution string `json:"elk.layered.edgeRouting.selfLoopDistribution,omitempty"`

	NodeSizeConstraints string `json:"elk.nodeSize.constraints,omitempty"`
	ContentAlignment    string `json:"elk.contentAlignment,omitempty"`
	NodeSizeMinimum     string `json:"elk.nodeSize.minimum,omitempty"`

	ConfigurableOpts
}

func DefaultLayout(ctx context.Context, g *d2graph.Graph) (err error) {
	return Layout(ctx, g, nil)
}

func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err error) {
	if opts == nil {
		opts = &DefaultOpts
	}
	defer xdefer.Errorf(&err, "failed to ELK layout")

	vm := goja.New()

	console := vm.NewObject()
	if err := vm.Set("console", console); err != nil {
		return err
	}

	if _, err := vm.RunString(elkJS); err != nil {
		return err
	}
	if _, err := vm.RunString(setupJS); err != nil {
		return err
	}

	elkGraph := &ELKGraph{
		ID: "",
		LayoutOptions: &elkOpts{
			Thoroughness:                 8,
			EdgeEdgeBetweenLayersSpacing: 50,
			EdgeNode:                     edge_node_spacing,
			HierarchyHandling:            "INCLUDE_CHILDREN",
			FixedAlignment:               "BALANCED",
			ConsiderModelOrder:           "NODES_AND_EDGES",
			CycleBreakingStrategy:        "GREEDY_MODEL_ORDER",
			NodeSizeConstraints:          "MINIMUM_SIZE",
			ContentAlignment:             "H_CENTER V_CENTER",
			ConfigurableOpts: ConfigurableOpts{
				Algorithm:       opts.Algorithm,
				NodeSpacing:     opts.NodeSpacing,
				EdgeNodeSpacing: opts.EdgeNodeSpacing,
				SelfLoopSpacing: opts.SelfLoopSpacing,
			},
		},
	}
	if elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
		// +5 for a tiny bit of padding
		elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(g.Root, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
	}
	switch g.Root.Direction.Value {
	case "down":
		elkGraph.LayoutOptions.Direction = "DOWN"
	case "up":
		elkGraph.LayoutOptions.Direction = "UP"
	case "right":
		elkGraph.LayoutOptions.Direction = "RIGHT"
	case "left":
		elkGraph.LayoutOptions.Direction = "LEFT"
	default:
		elkGraph.LayoutOptions.Direction = "DOWN"
	}

	// set label and icon positions for ELK
	for _, obj := range g.Objects {
		positionLabelsIcons(obj)
	}

	elkNodes := make(map[*d2graph.Object]*ELKNode)
	elkEdges := make(map[*d2graph.Edge]*ELKEdge)

	// BFS
	var walk func(*d2graph.Object, *d2graph.Object, func(*d2graph.Object, *d2graph.Object))
	walk = func(obj, parent *d2graph.Object, fn func(*d2graph.Object, *d2graph.Object)) {
		if obj.Parent != nil {
			fn(obj, parent)
		}
		for _, ch := range obj.ChildrenArray {
			walk(ch, obj, fn)
		}
	}

	walk(g.Root, nil, func(obj, parent *d2graph.Object) {
		incoming := 0.
		outgoing := 0.
		for _, e := range g.Edges {
			if e.Src == obj {
				outgoing++
			}
			if e.Dst == obj {
				incoming++
			}
		}
		if incoming >= 2 || outgoing >= 2 {
			switch g.Root.Direction.Value {
			case "right", "left":
				obj.Height = math.Max(obj.Height, math.Max(incoming, outgoing)*port_spacing)
			default:
				obj.Width = math.Max(obj.Width, math.Max(incoming, outgoing)*port_spacing)
			}
		}

		width, height := adjustDimensions(obj)

		n := &ELKNode{
			ID:     obj.AbsID(),
			Width:  width,
			Height: height,
		}

		if len(obj.ChildrenArray) > 0 {
			n.LayoutOptions = &elkOpts{
				ForceNodeModelOrder:          true,
				Thoroughness:                 8,
				EdgeEdgeBetweenLayersSpacing: 50,
				HierarchyHandling:            "INCLUDE_CHILDREN",
				FixedAlignment:               "BALANCED",
				EdgeNode:                     edge_node_spacing,
				ConsiderModelOrder:           "NODES_AND_EDGES",
				CycleBreakingStrategy:        "GREEDY_MODEL_ORDER",
				NodeSizeConstraints:          "MINIMUM_SIZE",
				ContentAlignment:             "H_CENTER V_CENTER",
				ConfigurableOpts: ConfigurableOpts{
					NodeSpacing:     opts.NodeSpacing,
					EdgeNodeSpacing: opts.EdgeNodeSpacing,
					SelfLoopSpacing: opts.SelfLoopSpacing,
					Padding:         opts.Padding,
				},
			}
			if n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
				n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(obj, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
			}

			switch elkGraph.LayoutOptions.Direction {
			case "DOWN", "UP":
				n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(height)), int(math.Ceil(width)))
			case "RIGHT", "LEFT":
				n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(width)), int(math.Ceil(height)))
			}
		} else {
			n.LayoutOptions = &elkOpts{
				SelfLoopDistribution: "EQUALLY",
			}
		}

		if obj.IsContainer() {
			padding := parsePadding(opts.Padding)
			padding = adjustPadding(obj, width, height, padding)
			n.LayoutOptions.Padding = padding.String()
		}

		if obj.HasLabel() {
			n.Labels = append(n.Labels, &ELKLabel{
				Text:   obj.Label.Value,
				Width:  float64(obj.LabelDimensions.Width),
				Height: float64(obj.LabelDimensions.Height),
			})
		}

		if parent == g.Root {
			elkGraph.Children = append(elkGraph.Children, n)
		} else {
			elkNodes[parent].Children = append(elkNodes[parent].Children, n)
		}
		elkNodes[obj] = n
	})

	// adjust parent padding for children with outside positioned icons
	for _, obj := range g.Objects {
		if !obj.IsContainer() {
			continue
		}

		var hasTop, hasBottom bool
		for _, child := range obj.ChildrenArray {
			if child.Shape.Value == d2target.ShapeImage || child.IconPosition == nil {
				continue
			}

			switch label.Position(*child.IconPosition) {
			case label.OutsideTopLeft, label.OutsideTopCenter, label.OutsideTopRight:
				hasTop = true
			case label.OutsideBottomLeft, label.OutsideBottomCenter, label.OutsideBottomRight:
				hasBottom = true
			}
			if hasTop && hasBottom {
				break
			}
		}

		if hasTop || hasBottom {
			padding := parsePadding(elkNodes[obj].LayoutOptions.Padding)
			if hasTop {
				padding.top = go2.Max(padding.top, d2target.MAX_ICON_SIZE+2*label.PADDING)
			}
			if hasBottom {
				padding.bottom = go2.Max(padding.bottom, d2target.MAX_ICON_SIZE+2*label.PADDING)
			}
			elkNodes[obj].LayoutOptions.Padding = padding.String()
		}
	}

	for _, edge := range g.Edges {
		e := &ELKEdge{
			ID:      edge.AbsID(),
			Sources: []string{edge.Src.AbsID()},
			Targets: []string{edge.Dst.AbsID()},
		}
		if edge.Label.Value != "" {
			e.Labels = append(e.Labels, &ELKLabel{
				Text:   edge.Label.Value,
				Width:  float64(edge.LabelDimensions.Width),
				Height: float64(edge.LabelDimensions.Height),
				LayoutOptions: &elkOpts{
					InlineEdgeLabels: true,
				},
			})
		}
		elkGraph.Edges = append(elkGraph.Edges, e)
		elkEdges[edge] = e
	}

	raw, err := json.Marshal(elkGraph)
	if err != nil {
		return err
	}

	loadScript := fmt.Sprintf(`var graph = %s`, raw)

	if _, err := vm.RunString(loadScript); err != nil {
		return err
	}

	val, err := vm.RunString(`elk.layout(graph)
.then(s => s)
.catch(err => err.message)
`)

	if err != nil {
		return err
	}

	p := val.Export()
	if err != nil {
		return err
	}

	promise := p.(*goja.Promise)

	for promise.State() == goja.PromiseStatePending {
		if err := ctx.Err(); err != nil {
			return err
		}
		continue
	}

	if promise.State() == goja.PromiseStateRejected {
		return errors.New("ELK: something went wrong")
	}

	result := promise.Result().Export()

	var jsonOut map[string]interface{}
	switch out := result.(type) {
	case string:
		return fmt.Errorf("ELK layout error: %s", out)
	case map[string]interface{}:
		jsonOut = out
	default:
		return fmt.Errorf("ELK unexpected return: %v", out)
	}

	jsonBytes, err := json.Marshal(jsonOut)
	if err != nil {
		return err
	}

	err = json.Unmarshal(jsonBytes, &elkGraph)
	if err != nil {
		return err
	}

	byID := make(map[string]*d2graph.Object)
	walk(g.Root, nil, func(obj, parent *d2graph.Object) {
		n := elkNodes[obj]

		parentX := 0.0
		parentY := 0.0
		if parent != nil && parent != g.Root {
			parentX = parent.TopLeft.X
			parentY = parent.TopLeft.Y
		}
		obj.TopLeft = geo.NewPoint(parentX+n.X, parentY+n.Y)
		obj.Width = math.Ceil(n.Width)
		obj.Height = math.Ceil(n.Height)

		byID[obj.AbsID()] = obj
	})

	for _, edge := range g.Edges {
		e := elkEdges[edge]

		parentX := 0.0
		parentY := 0.0
		if e.Container != "" {
			parentX = byID[e.Container].TopLeft.X
			parentY = byID[e.Container].TopLeft.Y
		}

		var points []*geo.Point
		for _, s := range e.Sections {
			points = append(points, &geo.Point{
				X: parentX + s.Start.X,
				Y: parentY + s.Start.Y,
			})
			for _, bp := range s.BendPoints {
				points = append(points, &geo.Point{
					X: parentX + bp.X,
					Y: parentY + bp.Y,
				})
			}
			points = append(points, &geo.Point{
				X: parentX + s.End.X,
				Y: parentY + s.End.Y,
			})
		}
		edge.Route = points
	}

	for _, obj := range g.Objects {
		cleanupAdjustment(obj)
	}

	for _, edge := range g.Edges {
		points := edge.Route

		startIndex, endIndex := 0, len(points)-1
		start := points[startIndex]
		end := points[endIndex]

		var originalSrcTL, originalDstTL *geo.Point
		// if the edge passes through 3d/multiple, use the offset box for tracing to border
		if srcDx, srcDy := edge.Src.GetModifierElementAdjustments(); srcDx != 0 || srcDy != 0 {
			if start.X > edge.Src.TopLeft.X+srcDx &&
				start.Y < edge.Src.TopLeft.Y+edge.Src.Height-srcDy {
				originalSrcTL = edge.Src.TopLeft.Copy()
				edge.Src.TopLeft.X += srcDx
				edge.Src.TopLeft.Y -= srcDy
			}
		}
		if dstDx, dstDy := edge.Dst.GetModifierElementAdjustments(); dstDx != 0 || dstDy != 0 {
			if end.X > edge.Dst.TopLeft.X+dstDx &&
				end.Y < edge.Dst.TopLeft.Y+edge.Dst.Height-dstDy {
				originalDstTL = edge.Dst.TopLeft.Copy()
				edge.Dst.TopLeft.X += dstDx
				edge.Dst.TopLeft.Y -= dstDy
			}
		}

		startIndex, endIndex = edge.TraceToShape(points, startIndex, endIndex)
		points = points[startIndex : endIndex+1]

		if edge.Label.Value != "" {
			edge.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
		}

		edge.Route = points

		// undo 3d/multiple offset
		if originalSrcTL != nil {
			edge.Src.TopLeft.X = originalSrcTL.X
			edge.Src.TopLeft.Y = originalSrcTL.Y
		}
		if originalDstTL != nil {
			edge.Dst.TopLeft.X = originalDstTL.X
			edge.Dst.TopLeft.Y = originalDstTL.Y
		}
	}

	deleteBends(g)

	return nil
}

// deleteBends is a shim for ELK to delete unnecessary bends
// see https://github.com/terrastruct/d2/issues/1030
func deleteBends(g *d2graph.Graph) {
	// Get rid of S-shapes at the source and the target
	// TODO there might be value in repeating this. removal of an S shape introducing another S shape that can still be removed
	for _, isSource := range []bool{true, false} {
		for ei, e := range g.Edges {
			if len(e.Route) < 4 {
				continue
			}
			if e.Src == e.Dst {
				continue
			}
			var endpoint *d2graph.Object
			var start *geo.Point
			var corner *geo.Point
			var end *geo.Point

			if isSource {
				start = e.Route[0]
				corner = e.Route[1]
				end = e.Route[2]
				endpoint = e.Src
			} else {
				start = e.Route[len(e.Route)-1]
				corner = e.Route[len(e.Route)-2]
				end = e.Route[len(e.Route)-3]
				endpoint = e.Dst
			}

			isHorizontal := math.Ceil(start.Y) == math.Ceil(corner.Y)
			dx, dy := endpoint.GetModifierElementAdjustments()

			// Make sure it's still attached
			if isHorizontal {
				if end.Y <= endpoint.TopLeft.Y+10-dy {
					continue
				}
				if end.Y >= endpoint.TopLeft.Y+endpoint.Height-10 {
					continue
				}
			} else {
				if end.X <= endpoint.TopLeft.X+10 {
					continue
				}
				if end.X >= endpoint.TopLeft.X+endpoint.Width-10+dx {
					continue
				}
			}

			var newStart *geo.Point
			if isHorizontal {
				newStart = geo.NewPoint(start.X, end.Y)
			} else {
				newStart = geo.NewPoint(end.X, start.Y)
			}

			endpointShape := shape.NewShape(d2target.DSL_SHAPE_TO_SHAPE_TYPE[strings.ToLower(endpoint.Shape.Value)], endpoint.Box)
			newStart = shape.TraceToShapeBorder(endpointShape, newStart, end)

			// Check that the new segment doesn't collide with anything new

			oldSegment := geo.NewSegment(start, corner)
			newSegment := geo.NewSegment(newStart, end)

			oldIntersects := countObjectIntersects(g, e.Src, e.Dst, *oldSegment)
			newIntersects := countObjectIntersects(g, e.Src, e.Dst, *newSegment)

			if newIntersects > oldIntersects {
				continue
			}

			oldCrossingsCount, oldOverlapsCount, oldCloseOverlapsCount, oldTouchingCount := countEdgeIntersects(g, g.Edges[ei], *oldSegment)
			newCrossingsCount, newOverlapsCount, newCloseOverlapsCount, newTouchingCount := countEdgeIntersects(g, g.Edges[ei], *newSegment)

			if newCrossingsCount > oldCrossingsCount {
				continue
			}
			if newOverlapsCount > oldOverlapsCount {
				continue
			}

			if newCloseOverlapsCount > oldCloseOverlapsCount {
				continue
			}
			if newTouchingCount > oldTouchingCount {
				continue
			}

			// commit
			if isSource {
				g.Edges[ei].Route = append(
					[]*geo.Point{newStart},
					e.Route[3:]...,
				)
			} else {
				g.Edges[ei].Route = append(
					e.Route[:len(e.Route)-3],
					newStart,
				)
			}
		}
	}
	// Get rid of ladders
	// ELK likes to do these for some reason
	// .   ┌─
	// . ┌─┘
	// . │
	// We want to transform these into L-shapes
	for ei, e := range g.Edges {
		if len(e.Route) < 6 {
			continue
		}
		if e.Src == e.Dst {
			continue
		}

		for i := 1; i < len(e.Route)-3; i++ {
			before := e.Route[i-1]
			start := e.Route[i]
			corner := e.Route[i+1]
			end := e.Route[i+2]
			after := e.Route[i+3]

			// S-shape on sources only concerned one segment, since the other was just along the bound of endpoint
			// These concern two segments

			var newCorner *geo.Point
			if math.Ceil(start.X) == math.Ceil(corner.X) {
				newCorner = geo.NewPoint(end.X, start.Y)
				// not ladder
				if (end.X > start.X) != (start.X > before.X) {
					continue
				}
				if (end.Y > start.Y) != (after.Y > end.Y) {
					continue
				}
			} else {
				newCorner = geo.NewPoint(start.X, end.Y)
				if (end.Y > start.Y) != (start.Y > before.Y) {
					continue
				}
				if (end.X > start.X) != (after.X > end.X) {
					continue
				}
			}

			oldS1 := geo.NewSegment(start, corner)
			oldS2 := geo.NewSegment(corner, end)

			newS1 := geo.NewSegment(start, newCorner)
			newS2 := geo.NewSegment(newCorner, end)

			// Check that the new segments doesn't collide with anything new
			oldIntersects := countObjectIntersects(g, e.Src, e.Dst, *oldS1) + countObjectIntersects(g, e.Src, e.Dst, *oldS2)
			newIntersects := countObjectIntersects(g, e.Src, e.Dst, *newS1) + countObjectIntersects(g, e.Src, e.Dst, *newS2)

			if newIntersects > oldIntersects {
				continue
			}

			oldCrossingsCount1, oldOverlapsCount1, oldCloseOverlapsCount1, oldTouchingCount1 := countEdgeIntersects(g, g.Edges[ei], *oldS1)
			oldCrossingsCount2, oldOverlapsCount2, oldCloseOverlapsCount2, oldTouchingCount2 := countEdgeIntersects(g, g.Edges[ei], *oldS2)
			oldCrossingsCount := oldCrossingsCount1 + oldCrossingsCount2
			oldOverlapsCount := oldOverlapsCount1 + oldOverlapsCount2
			oldCloseOverlapsCount := oldCloseOverlapsCount1 + oldCloseOverlapsCount2
			oldTouchingCount := oldTouchingCount1 + oldTouchingCount2

			newCrossingsCount1, newOverlapsCount1, newCloseOverlapsCount1, newTouchingCount1 := countEdgeIntersects(g, g.Edges[ei], *newS1)
			newCrossingsCount2, newOverlapsCount2, newCloseOverlapsCount2, newTouchingCount2 := countEdgeIntersects(g, g.Edges[ei], *newS2)
			newCrossingsCount := newCrossingsCount1 + newCrossingsCount2
			newOverlapsCount := newOverlapsCount1 + newOverlapsCount2
			newCloseOverlapsCount := newCloseOverlapsCount1 + newCloseOverlapsCount2
			newTouchingCount := newTouchingCount1 + newTouchingCount2

			if newCrossingsCount > oldCrossingsCount {
				continue
			}
			if newOverlapsCount > oldOverlapsCount {
				continue
			}

			if newCloseOverlapsCount > oldCloseOverlapsCount {
				continue
			}
			if newTouchingCount > oldTouchingCount {
				continue
			}

			// commit
			g.Edges[ei].Route = append(append(
				e.Route[:i],
				newCorner,
			),
				e.Route[i+3:]...,
			)
			break
		}
	}
}

func countObjectIntersects(g *d2graph.Graph, src, dst *d2graph.Object, s geo.Segment) int {
	count := 0
	for i, o := range g.Objects {
		if g.Objects[i] == src || g.Objects[i] == dst {
			continue
		}
		if o.Intersects(s, float64(edge_node_spacing)-1) {
			count++
		}
	}
	return count
}

// countEdgeIntersects counts both crossings AND getting too close to a parallel segment
func countEdgeIntersects(g *d2graph.Graph, sEdge *d2graph.Edge, s geo.Segment) (int, int, int, int) {
	isHorizontal := math.Ceil(s.Start.Y) == math.Ceil(s.End.Y)
	crossingsCount := 0
	overlapsCount := 0
	closeOverlapsCount := 0
	touchingCount := 0
	for i, e := range g.Edges {
		if g.Edges[i] == sEdge {
			continue
		}

		for i := 0; i < len(e.Route)-1; i++ {
			otherS := geo.NewSegment(e.Route[i], e.Route[i+1])
			otherIsHorizontal := math.Ceil(otherS.Start.Y) == math.Ceil(otherS.End.Y)
			if isHorizontal == otherIsHorizontal {
				if s.Overlaps(*otherS, !isHorizontal, 0.) {
					if isHorizontal {
						if math.Abs(s.Start.Y-otherS.Start.Y) < float64(edge_node_spacing)/2. {
							overlapsCount++
							if math.Abs(s.Start.Y-otherS.Start.Y) < float64(edge_node_spacing)/4. {
								closeOverlapsCount++
								if math.Abs(s.Start.Y-otherS.Start.Y) < 1. {
									touchingCount++
								}
							}
						}
					} else {
						if math.Abs(s.Start.X-otherS.Start.X) < float64(edge_node_spacing)/2. {
							overlapsCount++
							if math.Abs(s.Start.X-otherS.Start.X) < float64(edge_node_spacing)/4. {
								closeOverlapsCount++
								if math.Abs(s.Start.Y-otherS.Start.Y) < 1. {
									touchingCount++
								}
							}
						}
					}
				}
			} else {
				if s.Intersects(*otherS) {
					crossingsCount++
				}
			}
		}

	}
	return crossingsCount, overlapsCount, closeOverlapsCount, touchingCount
}

func childrenMaxSelfLoop(parent *d2graph.Object, isWidth bool) int {
	max := 0
	for _, ch := range parent.Children {
		for _, e := range parent.Graph.Edges {
			if e.Src == e.Dst && e.Src == ch && e.Label.Value != "" {
				if isWidth {
					max = go2.Max(max, e.LabelDimensions.Width)
				} else {
					max = go2.Max(max, e.LabelDimensions.Height)
				}
			}
		}
	}

	return max
}

type shapePadding struct {
	top, left, bottom, right int
}

// parse out values from elk padding string. e.g. "[top=50,left=50,bottom=50,right=50]"
func parsePadding(in string) shapePadding {
	reTop := regexp.MustCompile(`top=(\d+)`)
	reLeft := regexp.MustCompile(`left=(\d+)`)
	reBottom := regexp.MustCompile(`bottom=(\d+)`)
	reRight := regexp.MustCompile(`right=(\d+)`)

	padding := shapePadding{}

	submatches := reTop.FindStringSubmatch(in)
	if len(submatches) == 2 {
		i, err := strconv.ParseInt(submatches[1], 10, 64)
		if err == nil {
			padding.top = int(i)
		}
	}

	submatches = reLeft.FindStringSubmatch(in)
	if len(submatches) == 2 {
		i, err := strconv.ParseInt(submatches[1], 10, 64)
		if err == nil {
			padding.left = int(i)
		}
	}

	submatches = reBottom.FindStringSubmatch(in)
	if len(submatches) == 2 {
		i, err := strconv.ParseInt(submatches[1], 10, 64)
		if err == nil {
			padding.bottom = int(i)
		}
	}

	submatches = reRight.FindStringSubmatch(in)
	i, err := strconv.ParseInt(submatches[1], 10, 64)
	if len(submatches) == 2 {
		if err == nil {
			padding.right = int(i)
		}
	}

	return padding
}

func (padding shapePadding) String() string {
	return fmt.Sprintf("[top=%d,left=%d,bottom=%d,right=%d]", padding.top, padding.left, padding.bottom, padding.right)
}

func adjustPadding(obj *d2graph.Object, width, height float64, padding shapePadding) shapePadding {
	if !obj.IsContainer() {
		return padding
	}

	// compute extra space padding for label/icon
	var extraTop, extraBottom, extraLeft, extraRight int
	if obj.HasLabel() && obj.LabelPosition != nil {
		labelHeight := obj.LabelDimensions.Height + 2*label.PADDING
		labelWidth := obj.LabelDimensions.Width + 2*label.PADDING
		switch label.Position(*obj.LabelPosition) {
		case label.InsideTopLeft, label.InsideTopCenter, label.InsideTopRight:
			// Note: for corners we only add height
			extraTop = labelHeight
		case label.InsideBottomLeft, label.InsideBottomCenter, label.InsideBottomRight:
			extraBottom = labelHeight
		case label.InsideMiddleLeft:
			extraLeft = labelWidth
		case label.InsideMiddleRight:
			extraRight = labelWidth
		}
	}
	if obj.Icon != nil && obj.Shape.Value != d2target.ShapeImage && obj.IconPosition != nil {
		iconSize := d2target.MAX_ICON_SIZE + 2*label.PADDING
		switch label.Position(*obj.IconPosition) {
		case label.InsideTopLeft, label.InsideTopCenter, label.InsideTopRight:
			extraTop = go2.Max(extraTop, iconSize)
		case label.InsideBottomLeft, label.InsideBottomCenter, label.InsideBottomRight:
			extraBottom = go2.Max(extraBottom, iconSize)
		case label.InsideMiddleLeft:
			extraLeft = go2.Max(extraLeft, iconSize)
		case label.InsideMiddleRight:
			extraRight = go2.Max(extraRight, iconSize)
		}
	}

	maxChildWidth, maxChildHeight := math.Inf(-1), math.Inf(-1)
	for _, c := range obj.ChildrenArray {
		if c.Width > maxChildWidth {
			maxChildWidth = c.Width
		}
		if c.Height > maxChildHeight {
			maxChildHeight = c.Height
		}
	}
	// We don't know exactly what the shape dimensions will be after layout, but for more accurate innerBox dimensions,
	// we add the maxChildWidth and maxChildHeight with computed additions for the innerBox calculation
	width += maxChildWidth + float64(extraLeft+extraRight)
	height += maxChildHeight + float64(extraTop+extraBottom)
	contentBox := geo.NewBox(geo.NewPoint(0, 0), width, height)
	shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[obj.Shape.Value]
	s := shape.NewShape(shapeType, contentBox)
	innerBox := s.GetInnerBox()

	// If the shape inner box + label/icon height becomes greater than the default padding, we want to use that
	//
	// ┌OUTER───────────────────────────┬────────────────────────────────────────────┐
	// │                                │                                            │
	// │  ┌INNER──────── ┬ ─────────────│───────────────────────────────────────┐    │
	// │  │              │Label Padding │                                       │    │
	// │  │      ┌LABEL─ ┴ ─────────────│───────┐┬             ┌ICON── ┬ ────┐  │    │
	// │  │      │                      │       ││             │       │     │  │    │
	// │  │      │                      │       ││Label Height │   Icon│     │  │    │
	// │  │      │                      │       ││             │ Height│     │  │    │
	// │  │      └──────────────────────│───────┘┴             │       │     │  │    │
	// │  │                             │                      └────── ┴ ────┘  │    │
	// │  │                             │                                       │    │
	// │  │                             ┴Default ELK Padding                    │    │
	// │  │   ┌CHILD────────────────────────────────────────────────────────┐   │    │
	// │  │   │                                                             │   │    │
	// │  │   │                                                             │   │    │
	// │  │   │                                                             │   │    │
	// │  │   └─────────────────────────────────────────────────────────────┘   │    │
	// │  │                                                                     │    │
	// │  └─────────────────────────────────────────────────────────────────────┘    │
	// │                                                                             │
	// └─────────────────────────────────────────────────────────────────────────────┘

	// estimated shape innerBox padding
	innerTop := int(math.Ceil(innerBox.TopLeft.Y))
	innerBottom := int(math.Ceil(height - (innerBox.TopLeft.Y + innerBox.Height)))
	innerLeft := int(math.Ceil(innerBox.TopLeft.X))
	innerRight := int(math.Ceil(width - (innerBox.TopLeft.X + innerBox.Width)))

	padding.top = go2.Max(padding.top, innerTop+extraTop)
	padding.bottom = go2.Max(padding.bottom, innerBottom+extraBottom)
	padding.left = go2.Max(padding.left, innerLeft+extraLeft)
	padding.right = go2.Max(padding.right, innerRight+extraRight)

	return padding
}

func adjustDimensions(obj *d2graph.Object) (width, height float64) {
	width = obj.Width
	height = obj.Height

	// reserve spacing for labels
	if obj.HasLabel() {
		var position label.Position
		if obj.LabelPosition != nil {
			position = label.Position(*obj.LabelPosition)
		} else if len(obj.ChildrenArray) == 0 && obj.HasOutsideBottomLabel() {
			position = label.OutsideBottomCenter
		}

		if position.IsShapePosition() {
			switch position {
			case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom,
				label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
				width += float64(obj.LabelDimensions.Width) + label.PADDING
			default:
				// TODO labelWidth+2*label.PADDING
				width = go2.Max(width, float64(obj.LabelDimensions.Width))
			}
		}

		// special handling
		if obj.HasOutsideBottomLabel() || obj.Icon != nil {
			height += float64(obj.LabelDimensions.Height) + label.PADDING
		}
	}

	if obj.Icon != nil && obj.Shape.Value != d2target.ShapeImage {
		var position label.Position
		if obj.IconPosition != nil {
			position = label.Position(*obj.IconPosition)
		}

		if position.IsShapePosition() {
			switch position {
			case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom,
				label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
				width += d2target.MAX_ICON_SIZE + label.PADDING
			default:
				width = go2.Max(width, d2target.MAX_ICON_SIZE+2*label.PADDING)
			}
		}
	}

	// reserve extra space for 3d/multiple by providing elk the larger dimensions
	dx, dy := obj.GetModifierElementAdjustments()
	width += dx
	height += dy

	return
}

func cleanupAdjustment(obj *d2graph.Object) {
	// adjust size and position to account for space reserved for labels
	if obj.HasLabel() {
		position := label.Position(*obj.LabelPosition)
		if position.IsShapePosition() {
			var labelWidth float64
			switch position {
			case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom,
				label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
				labelWidth = float64(obj.LabelDimensions.Width) + label.PADDING
				obj.Width -= labelWidth
			}
			switch position {
			case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom:
				obj.TopLeft.X += labelWidth
				obj.ShiftDescendants(labelWidth/2, 0)
			case label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
				obj.ShiftDescendants(-labelWidth/2, 0)
			}
		}
	}
	if obj.Icon != nil && obj.Shape.Value != d2target.ShapeImage {
		position := label.Position(*obj.IconPosition)
		if position.IsShapePosition() {
			var iconWidth float64
			switch position {
			case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom,
				label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
				iconWidth = d2target.MAX_ICON_SIZE + label.PADDING
				obj.Width -= iconWidth
			}
			switch position {
			case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom:
				obj.TopLeft.X += iconWidth
				obj.ShiftDescendants(iconWidth/2, 0)
			case label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
				obj.ShiftDescendants(-iconWidth/2, 0)
			}
		}
	}

	// special handling to start/end connections below label
	if obj.HasOutsideBottomLabel() {
		obj.Height -= float64(obj.LabelDimensions.Height) + label.PADDING
	}

	// remove the extra width/height we added for 3d/multiple after all objects/connections are placed
	// and shift the shapes down accordingly
	dx, dy := obj.GetModifierElementAdjustments()
	if dx != 0 || dy != 0 {
		obj.TopLeft.Y += dy
		obj.ShiftDescendants(0, dy)
		if !obj.IsContainer() {
			obj.Width -= dx
			obj.Height -= dy
		}
	}
}

func positionLabelsIcons(obj *d2graph.Object) {
	if obj.Icon != nil && obj.IconPosition == nil {
		if len(obj.ChildrenArray) > 0 {
			obj.IconPosition = go2.Pointer(string(label.InsideTopLeft))
			if obj.LabelPosition == nil {
				obj.LabelPosition = go2.Pointer(string(label.InsideTopRight))
				return
			}
		} else {
			obj.IconPosition = go2.Pointer(string(label.InsideMiddleCenter))
		}
	}
	if obj.HasLabel() && obj.LabelPosition == nil {
		if len(obj.ChildrenArray) > 0 {
			obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
		} else if obj.HasOutsideBottomLabel() {
			obj.LabelPosition = go2.Pointer(string(label.OutsideBottomCenter))
		} else if obj.Icon != nil {
			obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
		} else {
			obj.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
		}
	}
}
