// © Broadcom. All Rights Reserved.
// The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries.
// SPDX-License-Identifier: Apache-2.0

package property

import (
	"context"
	"errors"
	"fmt"
	"sync"

	"github.com/vmware/govmomi/vim25"
	"github.com/vmware/govmomi/vim25/methods"
	"github.com/vmware/govmomi/vim25/mo"
	"github.com/vmware/govmomi/vim25/soap"
	"github.com/vmware/govmomi/vim25/types"
)

// ErrConcurrentCollector is returned from WaitForUpdates, WaitForUpdatesEx,
// or CheckForUpdates if any of those calls are unable to obtain an exclusive
// lock for the property collector.
var ErrConcurrentCollector = fmt.Errorf(
	"only one goroutine may invoke WaitForUpdates, WaitForUpdatesEx, " +
		"or CheckForUpdates on a given PropertyCollector")

// Collector models the PropertyCollector managed object.
//
// For more information, see:
// https://developer.broadcom.com/xapis/vsphere-web-services-api/latest/vmodl.query.PropertyCollector.html
type Collector struct {
	mu           sync.Mutex
	roundTripper soap.RoundTripper
	reference    types.ManagedObjectReference
}

// DefaultCollector returns the session's default property collector.
func DefaultCollector(c *vim25.Client) *Collector {
	p := Collector{
		roundTripper: c,
		reference:    c.ServiceContent.PropertyCollector,
	}

	return &p
}

func (p *Collector) Reference() types.ManagedObjectReference {
	return p.reference
}

// Create creates a new session-specific Collector that can be used to
// retrieve property updates independent of any other Collector.
func (p *Collector) Create(ctx context.Context) (*Collector, error) {
	req := types.CreatePropertyCollector{
		This: p.Reference(),
	}

	res, err := methods.CreatePropertyCollector(ctx, p.roundTripper, &req)
	if err != nil {
		return nil, err
	}

	newp := Collector{
		roundTripper: p.roundTripper,
		reference:    res.Returnval,
	}

	return &newp, nil
}

// Destroy destroys this Collector.
func (p *Collector) Destroy(ctx context.Context) error {
	req := types.DestroyPropertyCollector{
		This: p.Reference(),
	}

	_, err := methods.DestroyPropertyCollector(ctx, p.roundTripper, &req)
	if err != nil {
		return err
	}

	p.reference = types.ManagedObjectReference{}
	return nil
}

func (p *Collector) CreateFilter(ctx context.Context, req types.CreateFilter) (*Filter, error) {
	req.This = p.Reference()

	resp, err := methods.CreateFilter(ctx, p.roundTripper, &req)
	if err != nil {
		return nil, err
	}

	return &Filter{roundTripper: p.roundTripper, reference: resp.Returnval}, nil
}

// Deprecated: Please use WaitForUpdatesEx instead.
func (p *Collector) WaitForUpdates(
	ctx context.Context,
	version string,
	opts ...*types.WaitOptions) (*types.UpdateSet, error) {

	if !p.mu.TryLock() {
		return nil, ErrConcurrentCollector
	}
	defer p.mu.Unlock()

	req := types.WaitForUpdatesEx{
		This:    p.Reference(),
		Version: version,
	}

	if len(opts) == 1 {
		req.Options = opts[0]
	} else if len(opts) > 1 {
		panic("only one option may be specified")
	}

	res, err := methods.WaitForUpdatesEx(ctx, p.roundTripper, &req)
	if err != nil {
		return nil, err
	}

	return res.Returnval, nil
}

func (p *Collector) CancelWaitForUpdates(ctx context.Context) error {
	req := &types.CancelWaitForUpdates{This: p.Reference()}
	_, err := methods.CancelWaitForUpdates(ctx, p.roundTripper, req)
	return err
}

// RetrieveProperties wraps RetrievePropertiesEx and ContinueRetrievePropertiesEx to collect properties in batches.
func (p *Collector) RetrieveProperties(
	ctx context.Context,
	req types.RetrieveProperties,
	maxObjectsArgs ...int32) (*types.RetrievePropertiesResponse, error) {

	var opts types.RetrieveOptions
	if l := len(maxObjectsArgs); l > 1 {
		return nil, fmt.Errorf("maxObjectsArgs accepts a single value")
	} else if l == 1 {
		opts.MaxObjects = maxObjectsArgs[0]
	}

	objects, err := mo.RetrievePropertiesEx(ctx, p.roundTripper, types.RetrievePropertiesEx{
		This:    p.Reference(),
		SpecSet: req.SpecSet,
		Options: opts,
	})
	if err != nil {
		return nil, err
	}

	return &types.RetrievePropertiesResponse{Returnval: objects}, nil
}

// Retrieve loads properties for a slice of managed objects. The dst argument
// must be a pointer to a []interface{}, which is populated with the instances
// of the specified managed objects, with the relevant properties filled in. If
// the properties slice is nil, all properties are loaded.
// Note that pointer types are optional fields that may be left as a nil value.
// The caller should check such fields for a nil value before dereferencing.
func (p *Collector) Retrieve(ctx context.Context, objs []types.ManagedObjectReference, ps []string, dst any) error {
	if len(objs) == 0 {
		return errors.New("object references is empty")
	}

	kinds := make(map[string]bool)

	var propSet []types.PropertySpec
	var objectSet []types.ObjectSpec

	for _, obj := range objs {
		if _, ok := kinds[obj.Type]; !ok {
			spec := types.PropertySpec{
				Type: obj.Type,
			}
			if len(ps) == 0 {
				spec.All = types.NewBool(true)
			} else {
				spec.PathSet = ps
			}
			propSet = append(propSet, spec)
			kinds[obj.Type] = true
		}

		objectSpec := types.ObjectSpec{
			Obj:  obj,
			Skip: types.NewBool(false),
		}

		objectSet = append(objectSet, objectSpec)
	}

	req := types.RetrieveProperties{
		SpecSet: []types.PropertyFilterSpec{
			{
				ObjectSet: objectSet,
				PropSet:   propSet,
			},
		},
	}

	res, err := p.RetrieveProperties(ctx, req)
	if err != nil {
		return err
	}

	if d, ok := dst.(*[]types.ObjectContent); ok {
		*d = res.Returnval
		return nil
	}

	return mo.LoadObjectContent(res.Returnval, dst)
}

// RetrieveWithFilter populates dst as Retrieve does, but only for entities
// that match the specified filter.
func (p *Collector) RetrieveWithFilter(
	ctx context.Context,
	objs []types.ManagedObjectReference,
	ps []string,
	dst any,
	filter Match) error {

	if len(filter) == 0 {
		return p.Retrieve(ctx, objs, ps, dst)
	}

	var content []types.ObjectContent

	err := p.Retrieve(ctx, objs, filter.Keys(), &content)
	if err != nil {
		return err
	}

	objs = filter.ObjectContent(content)

	if len(objs) == 0 {
		return nil
	}

	return p.Retrieve(ctx, objs, ps, dst)
}

// RetrieveOne calls Retrieve with a single managed object reference via Collector.Retrieve().
func (p *Collector) RetrieveOne(ctx context.Context, obj types.ManagedObjectReference, ps []string, dst any) error {
	var objs = []types.ManagedObjectReference{obj}
	return p.Retrieve(ctx, objs, ps, dst)
}

// WaitForUpdatesEx waits for any of the specified properties of the specified
// managed object to change. It calls the specified function for every update it
// receives an update - including the empty filter set, which can occur if no
// objects are eligible for updates.
//
// If this function returns false, it continues waiting for
// subsequent updates. If this function returns true, it stops waiting and
// returns upon receiving the first non-empty filter set.
//
// If the Context is canceled, a call to CancelWaitForUpdates() is made and its
// error value is returned.
//
// By default, ObjectUpdate.MissingSet faults are not propagated to the returned
// error, set WaitFilter.PropagateMissing=true to enable MissingSet fault
// propagation.
func (p *Collector) WaitForUpdatesEx(
	ctx context.Context,
	opts *WaitOptions,
	onUpdatesFn func([]types.ObjectUpdate) bool) error {

	if !p.mu.TryLock() {
		return ErrConcurrentCollector
	}
	defer p.mu.Unlock()

	req := types.WaitForUpdatesEx{
		This:    p.Reference(),
		Options: opts.Options,
	}

	for {
		res, err := methods.WaitForUpdatesEx(ctx, p.roundTripper, &req)
		if err != nil {
			if ctx.Err() == context.Canceled {
				return p.CancelWaitForUpdates(context.Background())
			}
			return err
		}

		set := res.Returnval
		if set == nil {
			if req.Options != nil && req.Options.MaxWaitSeconds != nil {
				return nil // WaitOptions.MaxWaitSeconds exceeded
			}
			// Retry if the result came back empty
			continue
		}

		req.Version = set.Version
		opts.Truncated = false
		if set.Truncated != nil {
			opts.Truncated = *set.Truncated
		}

		if len(set.FilterSet) == 0 {
			// Trigger callbacks in case callers need to be notified
			// of the empty filter set.
			_ = onUpdatesFn(make([]types.ObjectUpdate, 0))
		}

		for _, fs := range set.FilterSet {
			if opts.PropagateMissing {
				for i := range fs.ObjectSet {
					for _, p := range fs.ObjectSet[i].MissingSet {
						// Same behavior as mo.ObjectContentToType()
						return soap.WrapVimFault(p.Fault.Fault)
					}
				}
			}

			if onUpdatesFn(fs.ObjectSet) {
				return nil
			}
		}
	}
}
