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

package object

import (
	"context"
	"fmt"
	"io"
	"math/rand"
	"net/http"
	"net/url"
	"os"
	"path"
	"strings"

	"github.com/vmware/govmomi/internal"
	"github.com/vmware/govmomi/property"
	"github.com/vmware/govmomi/session"
	"github.com/vmware/govmomi/vim25"
	"github.com/vmware/govmomi/vim25/mo"
	"github.com/vmware/govmomi/vim25/soap"
	"github.com/vmware/govmomi/vim25/types"
)

// DatastoreNoSuchDirectoryError is returned when a directory could not be found.
type DatastoreNoSuchDirectoryError struct {
	verb    string
	subject string
}

func (e DatastoreNoSuchDirectoryError) Error() string {
	return fmt.Sprintf("cannot %s '%s': No such directory", e.verb, e.subject)
}

// DatastoreNoSuchFileError is returned when a file could not be found.
type DatastoreNoSuchFileError struct {
	verb    string
	subject string
}

func (e DatastoreNoSuchFileError) Error() string {
	return fmt.Sprintf("cannot %s '%s': No such file", e.verb, e.subject)
}

type Datastore struct {
	Common

	DatacenterPath string
}

func NewDatastore(c *vim25.Client, ref types.ManagedObjectReference) *Datastore {
	return &Datastore{
		Common: NewCommon(c, ref),
	}
}

// FindInventoryPath sets InventoryPath and DatacenterPath,
// needed by NewURL() to compose an upload/download endpoint URL
func (d *Datastore) FindInventoryPath(ctx context.Context) error {
	entities, err := mo.Ancestors(ctx, d.c, d.c.ServiceContent.PropertyCollector, d.r)
	if err != nil {
		return err
	}

	val := "/"

	for _, entity := range entities {
		if entity.Parent == nil {
			continue // root folder
		}
		val = path.Join(val, entity.Name)
		if entity.Self.Type == "Datacenter" {
			d.DatacenterPath = val
		}
	}

	d.InventoryPath = val

	return nil
}

func (d Datastore) Path(path string) string {
	var p DatastorePath
	if p.FromString(path) {
		return p.String() // already in "[datastore] path" format
	}

	return (&DatastorePath{
		Datastore: d.Name(),
		Path:      path,
	}).String()
}

// NewDatastoreURL constructs a url.URL with the given file path for datastore access over HTTP.
func NewDatastoreURL(base url.URL, dcPath, dsName, path string) *url.URL {
	scheme := base.Scheme
	// In rare cases where vCenter and ESX are accessed using different schemes.
	if overrideScheme := os.Getenv("GOVMOMI_DATASTORE_ACCESS_SCHEME"); overrideScheme != "" {
		scheme = overrideScheme
	}

	base.Scheme = scheme
	base.Path = fmt.Sprintf("/folder/%s", path)
	base.RawQuery = url.Values{
		"dcPath": []string{dcPath},
		"dsName": []string{dsName},
	}.Encode()

	return &base
}

// NewURL constructs a url.URL with the given file path for datastore access over HTTP.
// The Datastore object is used to derive url, dcPath and dsName params to NewDatastoreURL.
// For dcPath, Datastore.DatacenterPath must be set and for dsName, Datastore.InventoryPath.
// This is the case when the object.Datastore instance is created by Finder.
// Otherwise, Datastore.FindInventoryPath should be called first, to set DatacenterPath
// and InventoryPath.
func (d Datastore) NewURL(path string) *url.URL {
	u := d.c.URL()
	return NewDatastoreURL(*u, d.DatacenterPath, d.Name(), path)
}

func (d Datastore) Browser(ctx context.Context) (*HostDatastoreBrowser, error) {
	var do mo.Datastore

	err := d.Properties(ctx, d.Reference(), []string{"browser"}, &do)
	if err != nil {
		return nil, err
	}

	return NewHostDatastoreBrowser(d.c, do.Browser), nil
}

func (d Datastore) useServiceTicket() bool {
	// If connected to workstation, service ticketing not supported
	// If connected to ESX, service ticketing not needed
	if !d.c.IsVC() {
		return false
	}

	key := "GOVMOMI_USE_SERVICE_TICKET"

	val := d.c.URL().Query().Get(key)
	if val == "" {
		val = os.Getenv(key)
	}

	if val == "1" || val == "true" {
		return true
	}

	return false
}

func (d Datastore) useServiceTicketHostName(name string) bool {
	// No need if talking directly to ESX.
	if !d.c.IsVC() {
		return false
	}

	// If version happens to be < 5.1
	if name == "" {
		return false
	}

	// If the HostSystem is using DHCP on a network without dynamic DNS,
	// HostSystem.Config.Network.DnsConfig.HostName is set to "localhost" by default.
	// This resolves to "localhost.localdomain" by default via /etc/hosts on ESX.
	// In that case, we will stick with the HostSystem.Name which is the IP address that
	// was used to connect the host to VC.
	if name == "localhost.localdomain" {
		return false
	}

	// Still possible to have HostName that don't resolve via DNS,
	// so we default to false.
	key := "GOVMOMI_USE_SERVICE_TICKET_HOSTNAME"

	val := d.c.URL().Query().Get(key)
	if val == "" {
		val = os.Getenv(key)
	}

	if val == "1" || val == "true" {
		return true
	}

	return false
}

type datastoreServiceTicketHostKey struct{}

// HostContext returns a Context where the given host will be used for datastore HTTP access
// via the ServiceTicket method.
func (d Datastore) HostContext(ctx context.Context, host *HostSystem) context.Context {
	return context.WithValue(ctx, datastoreServiceTicketHostKey{}, host)
}

// ServiceTicket obtains a ticket via AcquireGenericServiceTicket and returns it an http.Cookie with the url.URL
// that can be used along with the ticket cookie to access the given path.  An host is chosen at random unless the
// the given Context was created with a specific host via the HostContext method.
func (d Datastore) ServiceTicket(ctx context.Context, path string, method string) (*url.URL, *http.Cookie, error) {
	if d.InventoryPath == "" {
		_ = d.FindInventoryPath(ctx)
	}

	u := d.NewURL(path)

	host, ok := ctx.Value(datastoreServiceTicketHostKey{}).(*HostSystem)

	if !ok {
		if !d.useServiceTicket() {
			return u, nil, nil
		}

		hosts, err := d.AttachedHosts(ctx)
		if err != nil {
			return nil, nil, err
		}

		if len(hosts) == 0 {
			// Fallback to letting vCenter choose a host
			return u, nil, nil
		}

		// Pick a random attached host
		host = hosts[rand.Intn(len(hosts))]
	}

	ips, err := host.ManagementIPs(ctx)
	if err != nil {
		return nil, nil, err
	}

	if len(ips) > 0 {
		// prefer a ManagementIP
		u.Host = ips[0].String()
	} else {
		// fallback to inventory name
		u.Host, err = host.ObjectName(ctx)
		if err != nil {
			return nil, nil, err
		}
	}

	// VC datacenter path will not be valid against ESX
	q := u.Query()
	delete(q, "dcPath")
	u.RawQuery = q.Encode()

	// Now that we have a host selected, take a copy of the URL.
	transferURL := *u

	if internal.UsingEnvoySidecar(d.Client()) {
		// Rewrite the host URL to go through the Envoy sidecar on VC.
		// Reciever must use a custom dialer.
		u = internal.HostGatewayTransferURL(u, host.Reference())
	}

	spec := types.SessionManagerHttpServiceRequestSpec{
		// Use the original URL (without rewrites) for the session ticket.
		Url: transferURL.String(),
		// See SessionManagerHttpServiceRequestSpecMethod enum
		Method: fmt.Sprintf("http%s%s", method[0:1], strings.ToLower(method[1:])),
	}

	sm := session.NewManager(d.Client())

	ticket, err := sm.AcquireGenericServiceTicket(ctx, &spec)
	if err != nil {
		return nil, nil, err
	}

	cookie := &http.Cookie{
		Name:  "vmware_cgi_ticket",
		Value: ticket.Id,
	}

	if d.useServiceTicketHostName(ticket.HostName) {
		u.Host = ticket.HostName
	}

	d.Client().SetThumbprint(u.Host, ticket.SslThumbprint)

	return u, cookie, nil
}

func (d Datastore) uploadTicket(ctx context.Context, path string, param *soap.Upload) (*url.URL, *soap.Upload, error) {
	p := soap.DefaultUpload
	if param != nil {
		p = *param // copy
	}

	u, ticket, err := d.ServiceTicket(ctx, path, p.Method)
	if err != nil {
		return nil, nil, err
	}

	if ticket != nil {
		p.Ticket = ticket
		p.Close = true // disable Keep-Alive connection to ESX
	}

	return u, &p, nil
}

func (d Datastore) downloadTicket(ctx context.Context, path string, param *soap.Download) (*url.URL, *soap.Download, error) {
	p := soap.DefaultDownload
	if param != nil {
		p = *param // copy
	}

	u, ticket, err := d.ServiceTicket(ctx, path, p.Method)
	if err != nil {
		return nil, nil, err
	}

	if ticket != nil {
		p.Ticket = ticket
		p.Close = true // disable Keep-Alive connection to ESX
	}

	return u, &p, nil
}

// Upload via soap.Upload with an http service ticket
func (d Datastore) Upload(ctx context.Context, f io.Reader, path string, param *soap.Upload) error {
	u, p, err := d.uploadTicket(ctx, path, param)
	if err != nil {
		return err
	}
	return d.Client().Upload(ctx, f, u, p)
}

// UploadFile via soap.Upload with an http service ticket
func (d Datastore) UploadFile(ctx context.Context, file string, path string, param *soap.Upload) error {
	u, p, err := d.uploadTicket(ctx, path, param)
	if err != nil {
		return err
	}
	vc := d.Client()
	if internal.UsingEnvoySidecar(vc) {
		// Override the vim client with a new one that wraps a Unix socket transport.
		// Using HTTP here so secure means nothing.
		vc = internal.ClientWithEnvoyHostGateway(vc)
	}
	return vc.UploadFile(ctx, file, u, p)
}

// Download via soap.Download with an http service ticket
func (d Datastore) Download(ctx context.Context, path string, param *soap.Download) (io.ReadCloser, int64, error) {
	u, p, err := d.downloadTicket(ctx, path, param)
	if err != nil {
		return nil, 0, err
	}
	return d.Client().Download(ctx, u, p)
}

// DownloadFile via soap.Download with an http service ticket
func (d Datastore) DownloadFile(ctx context.Context, path string, file string, param *soap.Download) error {
	u, p, err := d.downloadTicket(ctx, path, param)
	if err != nil {
		return err
	}
	vc := d.Client()
	if internal.UsingEnvoySidecar(vc) {
		// Override the vim client with a new one that wraps a Unix socket transport.
		// Using HTTP here so secure means nothing.
		vc = internal.ClientWithEnvoyHostGateway(vc)
	}
	return vc.DownloadFile(ctx, file, u, p)
}

// AttachedHosts returns hosts that have this Datastore attached, accessible and writable.
func (d Datastore) AttachedHosts(ctx context.Context) ([]*HostSystem, error) {
	var ds mo.Datastore
	var hosts []*HostSystem

	pc := property.DefaultCollector(d.Client())
	err := pc.RetrieveOne(ctx, d.Reference(), []string{"host"}, &ds)
	if err != nil {
		return nil, err
	}

	mounts := make(map[types.ManagedObjectReference]types.DatastoreHostMount)
	var refs []types.ManagedObjectReference
	for _, host := range ds.Host {
		refs = append(refs, host.Key)
		mounts[host.Key] = host
	}

	var hs []mo.HostSystem
	err = pc.Retrieve(ctx, refs, []string{"runtime.connectionState", "runtime.powerState"}, &hs)
	if err != nil {
		return nil, err
	}

	for _, host := range hs {
		if host.Runtime.ConnectionState == types.HostSystemConnectionStateConnected &&
			host.Runtime.PowerState == types.HostSystemPowerStatePoweredOn {

			mount := mounts[host.Reference()]
			info := mount.MountInfo

			if *info.Mounted && *info.Accessible && info.AccessMode == string(types.HostMountModeReadWrite) {
				hosts = append(hosts, NewHostSystem(d.Client(), mount.Key))
			}
		}
	}

	return hosts, nil
}

// AttachedClusterHosts returns hosts that have this Datastore attached, accessible and writable and are members of the given cluster.
func (d Datastore) AttachedClusterHosts(ctx context.Context, cluster *ComputeResource) ([]*HostSystem, error) {
	var hosts []*HostSystem

	clusterHosts, err := cluster.Hosts(ctx)
	if err != nil {
		return nil, err
	}

	attachedHosts, err := d.AttachedHosts(ctx)
	if err != nil {
		return nil, err
	}

	refs := make(map[types.ManagedObjectReference]bool)
	for _, host := range attachedHosts {
		refs[host.Reference()] = true
	}

	for _, host := range clusterHosts {
		if refs[host.Reference()] {
			hosts = append(hosts, host)
		}
	}

	return hosts, nil
}

func (d Datastore) Stat(ctx context.Context, file string) (types.BaseFileInfo, error) {
	b, err := d.Browser(ctx)
	if err != nil {
		return nil, err
	}

	spec := types.HostDatastoreBrowserSearchSpec{
		Details: &types.FileQueryFlags{
			FileType:     true,
			FileSize:     true,
			Modification: true,
			FileOwner:    true,
		},
		MatchPattern: []string{path.Base(file)},
	}

	dsPath := d.Path(path.Dir(file))
	task, err := b.SearchDatastore(ctx, dsPath, &spec)
	if err != nil {
		return nil, err
	}

	info, err := task.WaitForResult(ctx, nil)
	if err != nil {
		if types.IsFileNotFound(err) {
			// FileNotFound means the base path doesn't exist.
			return nil, DatastoreNoSuchDirectoryError{"stat", dsPath}
		}

		return nil, err
	}

	res := info.Result.(types.HostDatastoreBrowserSearchResults)
	if len(res.File) == 0 {
		// File doesn't exist
		return nil, DatastoreNoSuchFileError{"stat", d.Path(file)}
	}

	return res.File[0], nil

}

// Type returns the type of file system volume.
func (d Datastore) Type(ctx context.Context) (types.HostFileSystemVolumeFileSystemType, error) {
	var mds mo.Datastore

	if err := d.Properties(ctx, d.Reference(), []string{"summary.type"}, &mds); err != nil {
		return types.HostFileSystemVolumeFileSystemType(""), err
	}
	return types.HostFileSystemVolumeFileSystemType(mds.Summary.Type), nil
}
