// Copyright 2020-2026 Buf Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package options

import (
	"fmt"

	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/reflect/protoreflect"
	"google.golang.org/protobuf/types/descriptorpb"

	"github.com/bufbuild/protocompile/internal/tags"
)

// StripSourceRetentionOptionsFromFile returns a file descriptor proto that omits any
// options in file that are defined to be retained only in source. If file has no
// such options, then it is returned as is. If it does have such options, a copy is
// made; the given file will not be mutated.
//
// Even when a copy is returned, it is not a deep copy: it may share data with the
// original file. So callers should not mutate the returned file unless mutating the
// input file is also safe.
func StripSourceRetentionOptionsFromFile(file *descriptorpb.FileDescriptorProto) (*descriptorpb.FileDescriptorProto, error) {
	var path sourcePath
	var removedPaths *sourcePathTrie
	if file.SourceCodeInfo != nil && len(file.SourceCodeInfo.Location) > 0 {
		path = make(sourcePath, 0, 16)
		removedPaths = &sourcePathTrie{}
	}
	var dirty bool
	optionsPath := path.push(tags.File_Options)
	newOpts, err := stripSourceRetentionOptions(file.GetOptions(), optionsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if newOpts != file.GetOptions() {
		dirty = true
	}
	msgsPath := path.push(tags.File_MessageType)
	newMsgs, changed, err := stripOptionsFromAll(file.GetMessageType(), stripSourceRetentionOptionsFromMessage, msgsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}
	enumsPath := path.push(tags.File_EnumType)
	newEnums, changed, err := stripOptionsFromAll(file.GetEnumType(), stripSourceRetentionOptionsFromEnum, enumsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}
	extsPath := path.push(tags.File_Extension)
	newExts, changed, err := stripOptionsFromAll(file.GetExtension(), stripSourceRetentionOptionsFromField, extsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}
	svcsPath := path.push(tags.File_Service)
	newSvcs, changed, err := stripOptionsFromAll(file.GetService(), stripSourceRetentionOptionsFromService, svcsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}

	if !dirty {
		return file, nil
	}

	newFile, err := shallowCopy(file)
	if err != nil {
		return nil, err
	}
	newFile.Options = newOpts
	newFile.MessageType = newMsgs
	newFile.EnumType = newEnums
	newFile.Extension = newExts
	newFile.Service = newSvcs
	newFile.SourceCodeInfo = stripSourcePathsForSourceRetentionOptions(newFile.SourceCodeInfo, removedPaths)
	return newFile, nil
}

type sourcePath protoreflect.SourcePath

func (p sourcePath) push(element int32) sourcePath {
	if p == nil {
		return nil
	}
	return append(p, element)
}

type sourcePathTrie struct {
	removed  bool
	children map[int32]*sourcePathTrie
}

func (t *sourcePathTrie) addPath(p sourcePath) {
	if t == nil {
		return
	}
	if len(p) == 0 {
		t.removed = true
		return
	}
	child := t.children[p[0]]
	if child == nil {
		if t.children == nil {
			t.children = map[int32]*sourcePathTrie{}
		}
		child = &sourcePathTrie{}
		t.children[p[0]] = child
	}
	child.addPath(p[1:])
}

func (t *sourcePathTrie) isRemoved(p []int32) bool {
	if t == nil {
		return false
	}
	if t.removed {
		return true
	}
	if len(p) == 0 {
		return false
	}
	child := t.children[p[0]]
	if child == nil {
		return false
	}
	return child.isRemoved(p[1:])
}

func stripSourceRetentionOptions[M proto.Message](
	options M,
	path sourcePath,
	removedPaths *sourcePathTrie,
) (M, error) {
	optionsRef := options.ProtoReflect()
	// See if there are any options to strip.
	var hasFieldToStrip bool
	var numFieldsToKeep int
	var err error
	optionsRef.Range(func(field protoreflect.FieldDescriptor, _ protoreflect.Value) bool {
		fieldOpts, ok := field.Options().(*descriptorpb.FieldOptions)
		if !ok {
			err = fmt.Errorf("field options is unexpected type: got %T, want %T", field.Options(), fieldOpts)
			return false
		}
		if fieldOpts.GetRetention() == descriptorpb.FieldOptions_RETENTION_SOURCE {
			hasFieldToStrip = true
		} else {
			numFieldsToKeep++
		}
		return true
	})
	var zero M
	if err != nil {
		return zero, err
	}
	if !hasFieldToStrip {
		return options, nil
	}

	if numFieldsToKeep == 0 {
		// Stripping the message would remove *all* options. In that case,
		// we'll clear out the options by returning the zero value (i.e. nil).
		removedPaths.addPath(path) // clear out all source locations, too
		return zero, nil
	}

	// There is at least one option to remove. So we need to make a copy that does not have those options.
	newOptions := optionsRef.New()
	ret, ok := newOptions.Interface().(M)
	if !ok {
		return zero, fmt.Errorf("creating new message of same type resulted in unexpected type; got %T, want %T", newOptions.Interface(), zero)
	}
	optionsRef.Range(func(field protoreflect.FieldDescriptor, val protoreflect.Value) bool {
		fieldOpts, ok := field.Options().(*descriptorpb.FieldOptions)
		if !ok {
			err = fmt.Errorf("field options is unexpected type: got %T, want %T", field.Options(), fieldOpts)
			return false
		}
		if fieldOpts.GetRetention() != descriptorpb.FieldOptions_RETENTION_SOURCE {
			newOptions.Set(field, val)
		} else {
			removedPaths.addPath(path.push(int32(field.Number())))
		}
		return true
	})
	if err != nil {
		return zero, err
	}
	return ret, nil
}

func stripSourceRetentionOptionsFromMessage(
	msg *descriptorpb.DescriptorProto,
	path sourcePath,
	removedPaths *sourcePathTrie,
) (*descriptorpb.DescriptorProto, error) {
	var dirty bool
	optionsPath := path.push(tags.Message_Options)
	newOpts, err := stripSourceRetentionOptions(msg.Options, optionsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if newOpts != msg.Options {
		dirty = true
	}
	fieldsPath := path.push(tags.Message_Field)
	newFields, changed, err := stripOptionsFromAll(msg.Field, stripSourceRetentionOptionsFromField, fieldsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}
	oneofsPath := path.push(tags.Message_OneofDecl)
	newOneofs, changed, err := stripOptionsFromAll(msg.OneofDecl, stripSourceRetentionOptionsFromOneof, oneofsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}
	extRangesPath := path.push(tags.Message_ExtensionRange)
	newExtRanges, changed, err := stripOptionsFromAll(msg.ExtensionRange, stripSourceRetentionOptionsFromExtensionRange, extRangesPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}
	msgsPath := path.push(tags.Message_NestedType)
	newMsgs, changed, err := stripOptionsFromAll(msg.NestedType, stripSourceRetentionOptionsFromMessage, msgsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}
	enumsPath := path.push(tags.Message_EnumType)
	newEnums, changed, err := stripOptionsFromAll(msg.EnumType, stripSourceRetentionOptionsFromEnum, enumsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}
	extsPath := path.push(tags.Message_Extension)
	newExts, changed, err := stripOptionsFromAll(msg.Extension, stripSourceRetentionOptionsFromField, extsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}

	if !dirty {
		return msg, nil
	}

	newMsg, err := shallowCopy(msg)
	if err != nil {
		return nil, err
	}
	newMsg.Options = newOpts
	newMsg.Field = newFields
	newMsg.OneofDecl = newOneofs
	newMsg.ExtensionRange = newExtRanges
	newMsg.NestedType = newMsgs
	newMsg.EnumType = newEnums
	newMsg.Extension = newExts
	return newMsg, nil
}

func stripSourceRetentionOptionsFromField(
	field *descriptorpb.FieldDescriptorProto,
	path sourcePath,
	removedPaths *sourcePathTrie,
) (*descriptorpb.FieldDescriptorProto, error) {
	optionsPath := path.push(tags.Field_Options)
	newOpts, err := stripSourceRetentionOptions(field.Options, optionsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if newOpts == field.Options {
		return field, nil
	}
	newField, err := shallowCopy(field)
	if err != nil {
		return nil, err
	}
	newField.Options = newOpts
	return newField, nil
}

func stripSourceRetentionOptionsFromOneof(
	oneof *descriptorpb.OneofDescriptorProto,
	path sourcePath,
	removedPaths *sourcePathTrie,
) (*descriptorpb.OneofDescriptorProto, error) {
	optionsPath := path.push(tags.Oneof_Options)
	newOpts, err := stripSourceRetentionOptions(oneof.Options, optionsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if newOpts == oneof.Options {
		return oneof, nil
	}
	newOneof, err := shallowCopy(oneof)
	if err != nil {
		return nil, err
	}
	newOneof.Options = newOpts
	return newOneof, nil
}

func stripSourceRetentionOptionsFromExtensionRange(
	extRange *descriptorpb.DescriptorProto_ExtensionRange,
	path sourcePath,
	removedPaths *sourcePathTrie,
) (*descriptorpb.DescriptorProto_ExtensionRange, error) {
	optionsPath := path.push(tags.Message_ExtensionRange_Options)
	newOpts, err := stripSourceRetentionOptions(extRange.Options, optionsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if newOpts == extRange.Options {
		return extRange, nil
	}
	newExtRange, err := shallowCopy(extRange)
	if err != nil {
		return nil, err
	}
	newExtRange.Options = newOpts
	return newExtRange, nil
}

func stripSourceRetentionOptionsFromEnum(
	enum *descriptorpb.EnumDescriptorProto,
	path sourcePath,
	removedPaths *sourcePathTrie,
) (*descriptorpb.EnumDescriptorProto, error) {
	var dirty bool
	optionsPath := path.push(tags.Enum_Options)
	newOpts, err := stripSourceRetentionOptions(enum.Options, optionsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if newOpts != enum.Options {
		dirty = true
	}
	valsPath := path.push(tags.Enum_Value)
	newVals, changed, err := stripOptionsFromAll(enum.Value, stripSourceRetentionOptionsFromEnumValue, valsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}

	if !dirty {
		return enum, nil
	}

	newEnum, err := shallowCopy(enum)
	if err != nil {
		return nil, err
	}
	newEnum.Options = newOpts
	newEnum.Value = newVals
	return newEnum, nil
}

func stripSourceRetentionOptionsFromEnumValue(
	enumVal *descriptorpb.EnumValueDescriptorProto,
	path sourcePath,
	removedPaths *sourcePathTrie,
) (*descriptorpb.EnumValueDescriptorProto, error) {
	optionsPath := path.push(tags.EnumValue_Options)
	newOpts, err := stripSourceRetentionOptions(enumVal.Options, optionsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if newOpts == enumVal.Options {
		return enumVal, nil
	}
	newEnumVal, err := shallowCopy(enumVal)
	if err != nil {
		return nil, err
	}
	newEnumVal.Options = newOpts
	return newEnumVal, nil
}

func stripSourceRetentionOptionsFromService(
	svc *descriptorpb.ServiceDescriptorProto,
	path sourcePath,
	removedPaths *sourcePathTrie,
) (*descriptorpb.ServiceDescriptorProto, error) {
	var dirty bool
	optionsPath := path.push(tags.Service_Options)
	newOpts, err := stripSourceRetentionOptions(svc.Options, optionsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if newOpts != svc.Options {
		dirty = true
	}
	methodsPath := path.push(tags.Service_Method)
	newMethods, changed, err := stripOptionsFromAll(svc.Method, stripSourceRetentionOptionsFromMethod, methodsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if changed {
		dirty = true
	}

	if !dirty {
		return svc, nil
	}

	newSvc, err := shallowCopy(svc)
	if err != nil {
		return nil, err
	}
	newSvc.Options = newOpts
	newSvc.Method = newMethods
	return newSvc, nil
}

func stripSourceRetentionOptionsFromMethod(
	method *descriptorpb.MethodDescriptorProto,
	path sourcePath,
	removedPaths *sourcePathTrie,
) (*descriptorpb.MethodDescriptorProto, error) {
	optionsPath := path.push(tags.Method_Options)
	newOpts, err := stripSourceRetentionOptions(method.Options, optionsPath, removedPaths)
	if err != nil {
		return nil, err
	}
	if newOpts == method.Options {
		return method, nil
	}
	newMethod, err := shallowCopy(method)
	if err != nil {
		return nil, err
	}
	newMethod.Options = newOpts
	return newMethod, nil
}

func stripSourcePathsForSourceRetentionOptions(
	sourceInfo *descriptorpb.SourceCodeInfo,
	removedPaths *sourcePathTrie,
) *descriptorpb.SourceCodeInfo {
	if sourceInfo == nil || len(sourceInfo.Location) == 0 || removedPaths == nil {
		// nothing to do
		return sourceInfo
	}
	newLocations := make([]*descriptorpb.SourceCodeInfo_Location, len(sourceInfo.Location))
	var i int
	for _, loc := range sourceInfo.Location {
		if removedPaths.isRemoved(loc.Path) {
			continue
		}
		newLocations[i] = loc
		i++
	}
	newLocations = newLocations[:i]
	return &descriptorpb.SourceCodeInfo{Location: newLocations}
}

func shallowCopy[M proto.Message](msg M) (M, error) {
	msgRef := msg.ProtoReflect()
	other := msgRef.New()
	ret, ok := other.Interface().(M)
	if !ok {
		return ret, fmt.Errorf("creating new message of same type resulted in unexpected type; got %T, want %T", other.Interface(), ret)
	}
	msgRef.Range(func(field protoreflect.FieldDescriptor, val protoreflect.Value) bool {
		other.Set(field, val)
		return true
	})
	return ret, nil
}

// stripOptionsFromAll applies the given function to each element in the given
// slice in order to remove source-retention options from it. It returns the new
// slice and a bool indicating whether anything was actually changed. If the
// second value is false, then the returned slice is the same slice as the input
// slice. Usually, T is a pointer type, in which case the given updateFunc should
// NOT mutate the input value. Instead, it should return the input value if only
// if there is no update needed. If a mutation is needed, it should return a new
// value.
func stripOptionsFromAll[T comparable](
	slice []T,
	updateFunc func(T, sourcePath, *sourcePathTrie) (T, error),
	path sourcePath,
	removedPaths *sourcePathTrie,
) ([]T, bool, error) {
	var updated []T // initialized lazily, only when/if a copy is needed
	for i, item := range slice {
		newItem, err := updateFunc(item, path.push(int32(i)), removedPaths)
		if err != nil {
			return nil, false, err
		}
		if updated != nil {
			updated[i] = newItem
		} else if newItem != item {
			updated = make([]T, len(slice))
			copy(updated[:i], slice)
			updated[i] = newItem
		}
	}
	if updated != nil {
		return updated, true, nil
	}
	return slice, false, nil
}
