// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package jobparser

import (
	"errors"
	"io"

	"gitea.com/gitea/runner/act/model"
	"go.yaml.in/yaml/v4"
)

// maxExpandedNodes bounds how many nodes alias expansion may create. go-yaml's own alias guard does
// not cover us: it only counts while decoding into values, and a workflow is kept as raw yaml.Nodes.
const maxExpandedNodes = 50000

var errTooManyYamlNodes = errors.New("maximum YAML nodes exceeded")

// ReadWorkflow decodes a workflow file with its aliases expanded. Callers inspect the workflow's
// raw nodes by kind, and an alias is a kind none of them expect.
func ReadWorkflow(content []byte) (*model.Workflow, error) {
	doc, err := resolveYamlAliases(content)
	if err != nil {
		return nil, err
	}
	return readWorkflowDoc(doc)
}

func readWorkflowDoc(doc *yaml.Node) (*model.Workflow, error) {
	if doc.Kind == 0 {
		return nil, io.EOF // what a yaml decoder reports for an empty file
	}
	w := new(model.Workflow)
	return w, doc.Decode(w)
}

// decodeResolved is yaml.Unmarshal with aliases expanded first.
func decodeResolved(content []byte, out any) error {
	doc, err := resolveYamlAliases(content)
	if err != nil {
		return err
	}
	return decodeYamlDoc(doc, out)
}

func decodeYamlDoc(doc *yaml.Node, out any) error {
	if doc.Kind == 0 {
		return nil // an empty document, as yaml.Unmarshal treats it
	}
	return doc.Decode(out)
}

// resolveYamlAliases parses content and replaces every alias with a copy of the node its anchor names.
func resolveYamlAliases(content []byte) (*yaml.Node, error) {
	doc := &yaml.Node{}
	if err := yaml.Unmarshal(content, doc); err != nil {
		return nil, err
	}
	budget := maxExpandedNodes
	return doc, expandAliases(doc, &budget)
}

// expandAliases replaces node's alias descendants in place.
func expandAliases(node *yaml.Node, budget *int) error {
	node.Anchor = "" // a name for a node, not part of the workflow: keep it out of the payloads
	if err := rejectMergeKeys(node); err != nil {
		return err
	}
	for i, child := range node.Content {
		if child.Kind != yaml.AliasNode {
			if err := expandAliases(child, budget); err != nil {
				return err
			}
			continue
		}
		copied, err := copyExpanded(child.Alias, budget)
		if err != nil {
			return err
		}
		node.Content[i] = copied
	}
	return nil
}

// copyExpanded deep copies a node expandAliases already expanded and validated, since an anchor is
// declared before the alias naming it. An anchor aliased from inside itself is the exception, and
// recurses here until it exhausts budget.
func copyExpanded(node *yaml.Node, budget *int) (*yaml.Node, error) {
	if *budget--; *budget < 0 {
		return nil, errTooManyYamlNodes
	}
	if node.Kind == yaml.AliasNode {
		return copyExpanded(node.Alias, budget)
	}

	copied := *node
	copied.Content = make([]*yaml.Node, len(node.Content))
	for i, child := range node.Content {
		child, err := copyExpanded(child, budget)
		if err != nil {
			return nil, err
		}
		copied.Content[i] = child
	}
	return &copied, nil
}

// rejectMergeKeys refuses `<<: *anchor`, same as GitHub does
func rejectMergeKeys(node *yaml.Node) error {
	if node.Kind != yaml.MappingNode {
		return nil
	}
	for i := 0; i < len(node.Content)-1; i += 2 {
		if node.Content[i].Tag == "!!merge" {
			return errors.New("merge keys (`<<`) are not supported, alias the whole value instead")
		}
	}
	return nil
}
