package yaml

import (
	"bytes"
	"errors"

	"gopkg.in/yaml.v3"
)

func Unmarshal(in []byte, out interface{}) (err error) {
	return yaml.Unmarshal(in, out)
}

func Encode(v any, indent int) ([]byte, error) {
	b := bytes.NewBuffer(nil)
	e := yaml.NewEncoder(b)
	e.SetIndent(indent)

	if err := e.Encode(v); err != nil {
		return nil, err
	}

	return b.Bytes(), nil
}

func Patch(in []byte, path []string, value any) ([]byte, error) {
	out, err := patch(in, path, value)
	if err != nil {
		return nil, err
	}

	// validate
	if err = yaml.Unmarshal(out, map[string]any{}); err != nil {
		return nil, err
	}

	return out, nil
}

func patch(in []byte, path []string, value any) ([]byte, error) {
	var root yaml.Node
	if err := yaml.Unmarshal(in, &root); err != nil {
		// invalid yaml
		return nil, err
	}

	// empty in
	if len(root.Content) != 1 {
		return addToEnd(in, path, value)
	}

	// yaml is not dict
	if root.Content[0].Kind != yaml.MappingNode {
		return nil, errors.New("yaml: can't patch")
	}

	// dict items list
	nodes := root.Content[0].Content

	n := len(path) - 1

	// parent node key/value
	pKey, pVal := findNode(nodes, path[:n])
	if pKey == nil {
		// no parent node
		return addToEnd(in, path, value)
	}

	var paste []byte

	if value != nil {
		// nil value means delete key
		var err error
		v := map[string]any{path[n]: value}
		if paste, err = Encode(v, 2); err != nil {
			return nil, err
		}
	}

	iKey, _ := findNode(pVal.Content, path[n:])
	if iKey != nil {
		// key item not nil (replace value)
		paste = addIndent(paste, iKey.Column-1)

		i0, i1 := nodeBounds(in, iKey)
		return join(in[:i0], paste, in[i1:]), nil
	}

	if pVal.Content != nil {
		// parent value not nil (use first child indent)
		paste = addIndent(paste, pVal.Column-1)
	} else {
		// parent value is nil (use parent indent + 2)
		paste = addIndent(paste, pKey.Column+1)
	}

	_, i1 := nodeBounds(in, pKey)
	return join(in[:i1], paste, in[i1:]), nil
}

func findNode(nodes []*yaml.Node, keys []string) (key, value *yaml.Node) {
	for i, name := range keys {
		for j := 0; j < len(nodes); j += 2 {
			if nodes[j].Value == name {
				if i < len(keys)-1 {
					nodes = nodes[j+1].Content
					break
				}
				return nodes[j], nodes[j+1]
			}
		}
	}
	return nil, nil
}

func nodeBounds(in []byte, node *yaml.Node) (offset0, offset1 int) {
	// start from next line after node
	offset0 = lineOffset(in, node.Line)
	offset1 = lineOffset(in, node.Line+1)

	if offset1 < 0 {
		return offset0, len(in)
	}

	for i := offset1; i < len(in); {
		indent, length := parseLine(in[i:])
		if indent+1 != length {
			if node.Column < indent+1 {
				offset1 = i + length
			} else {
				break
			}
		}
		i += length
	}

	return
}

func addToEnd(in []byte, path []string, value any) ([]byte, error) {
	if len(path) != 2 || value == nil {
		return nil, errors.New("yaml: path not exist")
	}

	v := map[string]map[string]any{
		path[0]: {path[1]: value},
	}
	paste, err := Encode(v, 2)
	if err != nil {
		return nil, err
	}

	return join(in, paste), nil
}

func join(items ...[]byte) []byte {
	n := len(items) - 1
	for _, b := range items {
		n += len(b)
	}

	buf := make([]byte, 0, n)
	for _, b := range items {
		if len(b) == 0 {
			continue
		}
		if n = len(buf); n > 0 && buf[n-1] != '\n' {
			buf = append(buf, '\n')
		}
		buf = append(buf, b...)
	}

	return buf
}

func addPrefix(src, pre []byte) (dst []byte) {
	for len(src) > 0 {
		dst = append(dst, pre...)
		i := bytes.IndexByte(src, '\n') + 1
		if i == 0 {
			dst = append(dst, src...)
			break
		}
		dst = append(dst, src[:i]...)
		src = src[i:]
	}

	return
}

func addIndent(in []byte, indent int) (dst []byte) {
	pre := make([]byte, indent)
	for i := 0; i < indent; i++ {
		pre[i] = ' '
	}
	return addPrefix(in, pre)
}

func lineOffset(in []byte, line int) (offset int) {
	for l := 1; ; l++ {
		if l == line {
			return offset
		}

		i := bytes.IndexByte(in[offset:], '\n') + 1
		if i == 0 {
			break
		}
		offset += i
	}
	return -1
}

func parseLine(b []byte) (indent int, length int) {
	prefix := true
	for ; length < len(b); length++ {
		switch b[length] {
		case ' ':
			if prefix {
				indent++
			}
		case '\n':
			length++
			return
		default:
			prefix = false
		}
	}
	return
}
