// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package core

import (
	"archive/tar"
	"context"
	"encoding/json"
	"errors"
	"io"
	"io/ioutil"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/hashicorp/go-hclog"
	"github.com/hashicorp/go-multierror"
	"github.com/hashicorp/go-version"
	"github.com/hashicorp/vagrant-plugin-sdk/core"
	"github.com/hashicorp/vagrant-plugin-sdk/helper/path"
	"github.com/hashicorp/vagrant-plugin-sdk/localizer"
	"github.com/hashicorp/vagrant-plugin-sdk/proto/vagrant_plugin_sdk"
	"github.com/hashicorp/vagrant/internal/server/proto/vagrant_server"
	"github.com/mitchellh/mapstructure"
	"google.golang.org/protobuf/types/known/structpb"
	"google.golang.org/protobuf/types/known/timestamppb"
)

// Number of seconds to wait between checks for box updates
const BoxUpdateCheckInterval = 3600

var RequiredMetadataFields = []string{"provider"}

type Box struct {
	basis  *Basis
	box    *vagrant_server.Box
	logger hclog.Logger
	m      sync.Mutex
}

func NewBox(opts ...BoxOption) (b *Box, err error) {
	b = &Box{
		logger: hclog.L(),
		box:    &vagrant_server.Box{},
	}

	for _, opt := range opts {
		if oerr := opt(b); oerr != nil {
			err = multierror.Append(err, oerr)
		}
	}

	if b.basis == nil {
		return nil, errors.New("Basis must be specified for the box")
	}

	if b.box.Directory == "" {
		return nil, errors.New("Box directory must be specified for the box")

	}

	metadataFile := filepath.Join(b.box.Directory, "metadata.json")
	if _, err := os.Stat(metadataFile); err != nil {
		return nil, localizer.LocalizeErr(
			"box_does_not_have_metadata_json_file",
			map[string]string{"BoxName": b.box.Name},
		)
	}

	data, err := os.ReadFile(metadataFile)
	if err != nil {
		return nil, err
	}
	metadata := make(map[string]interface{})
	if err := json.Unmarshal(data, &metadata); err != nil {
		return nil, err
	}
	for _, field := range RequiredMetadataFields {
		// If the metadata does not have a required field
		if _, ok := metadata[field]; !ok {
			return nil, localizer.LocalizeErr(
				"box_metadata_missing_required_fields",
				map[string]string{
					"BoxName":        b.box.Name,
					"RequiredField":  field,
					"RequiredFields": strings.Join(RequiredMetadataFields, " ,"),
				},
			)
		}
	}
	b.box.Metadata, err = structpb.NewStruct(metadata)
	if err != nil {
		return nil, err
	}
	// The metadata should have provider info under the "provider" key
	b.box.Provider = metadata["provider"].(string)
	b.box.ResourceId = b.box.Name + "-" + b.box.Version + "-" + b.box.Provider
	return
}

type BoxOption func(*Box) error

func BoxWithRef(ref *vagrant_plugin_sdk.Ref_Box, ctx context.Context) BoxOption {
	return func(b *Box) (err error) {
		boxResponse, err := b.basis.client.GetBox(
			ctx,
			&vagrant_server.GetBoxRequest{Box: ref},
		)
		b.box = boxResponse.Box
		return
	}
}

func BoxWithLogger(log hclog.Logger) BoxOption {
	return func(b *Box) (err error) {
		b.logger = log
		return
	}
}

func BoxWithBasis(basis *Basis) BoxOption {
	return func(b *Box) (err error) {
		b.basis = basis
		return
	}
}

func BoxWithName(name string) BoxOption {
	return func(b *Box) (err error) {
		b.box.Name = name
		return
	}
}

func BoxWithVersion(ver string) BoxOption {
	return func(b *Box) (err error) {
		_, err = version.NewVersion(ver)
		if err != nil {
			return err
		}
		b.box.Version = ver
		return
	}
}

func BoxWithProvider(provider string) BoxOption {
	return func(b *Box) (err error) {
		b.box.Provider = provider
		return
	}
}

func BoxWithMetadataUrl(url string) BoxOption {
	return func(b *Box) (err error) {
		b.box.MetadataUrl = url
		return
	}
}

func BoxWithDirectory(dir string) BoxOption {
	return func(b *Box) (err error) {
		if _, err := os.Stat(dir); err != nil {
			return err
		}
		b.box.Directory = dir
		return
	}
}

func BoxWithBox(box *vagrant_server.Box) BoxOption {
	return func(b *Box) (err error) {
		b.box = box
		return
	}
}

func (b *Box) loadMetadata() (metadata *BoxMetadata, err error) {
	client := &http.Client{}
	req, err := http.NewRequest("GET", b.box.MetadataUrl, nil)
	req.Header.Add("Accept", "application/json")
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	data, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	return LoadBoxMetadata(data)
}

func (b *Box) matches(box core.Box) (bool, error) {
	if box == nil {
		return false, nil
	}
	name, err := box.Name()
	if err != nil {
		return false, err
	}
	version, err := box.Version()
	if err != nil {
		return false, err
	}
	provider, err := box.Provider()
	if err != nil {
		return false, err
	}
	if b.box.Name == name &&
		b.box.Version == version &&
		b.box.Provider == provider {
		return true, nil
	}
	return false, nil
}

// Check if a box update check is allowed. Returns true if the
// BOX_UPDATE_CHECK_INTERVAL has passed.
func (b *Box) AutomaticUpdateCheckAllowed() (allowed bool, err error) {
	now := time.Now()
	lastUpdate := b.box.LastUpdate.AsTime()
	if lastUpdate.Add(BoxUpdateCheckInterval * time.Second).After(now) {
		return false, nil
	}
	b.box.LastUpdate = timestamppb.Now()
	b.Save()
	return true, nil
}

// This deletes the box. This is NOT undoable.
func (b *Box) Destroy() (err error) {
	b.m.Lock()
	defer b.m.Unlock()
	b.logger.Trace("deleting box from db",
		"box", b.box.Name)

	_, err = b.basis.client.DeleteBox(
		b.basis.ctx,
		&vagrant_server.DeleteBoxRequest{Box: &vagrant_plugin_sdk.Ref_Box{
			ResourceId: b.box.ResourceId, Name: b.box.Name, Version: b.box.Version, Provider: b.box.Provider,
		}},
	)
	if err != nil {
		b.logger.Trace("failed to delete box",
			"box", b.box.Name)
	}

	if fs, _ := os.Stat(b.box.Directory); fs != nil {
		b.logger.Trace("Removing box files",
			"path", b.box.Directory)
		return os.RemoveAll(b.box.Directory)
	}

	return
}

func (b *Box) Directory() (path.Path, error) {
	return path.NewPath(b.box.Directory), nil
}

// Checks if the box has an update and returns the metadata, version,
// and provider. If the box doesn't have an update that satisfies the
// constraints, it will return nil.
func (b *Box) HasUpdate(version string) (updateAvailable bool, err error) {
	updateAvailable, _, _, _, err = b.UpdateInfo(version)
	return
}

func (b *Box) UpdateInfo(version string) (updateAvailable bool, meta core.BoxMetadata, newVersion string, newProvider string, err error) {
	metadata, err := b.loadMetadata()
	if err != nil {
		return false, nil, "", "", err
	}
	versionConstraint := ""
	if version == "" {
		versionConstraint = "> " + b.box.Version
	} else {
		versionConstraint = version + ", " + "> " + b.box.Version
	}
	result, err := metadata.Version(
		versionConstraint,
		&core.BoxProvider{Name: b.box.Provider},
	)
	if err != nil {
		return false, nil, "", "", err
	}
	if result == nil {
		return false, nil, "", "", nil
	}
	return true, metadata, result.Version, b.box.Provider, nil
}

// Checks if this box is in use according to the given machine
// index and returns the entries that appear to be using the box.
func (b *Box) InUse(index core.TargetIndex) (inUse bool, err error) {
	targets, err := index.All()
	if err != nil {
		return false, err
	}
	for _, t := range targets {
		m, err := t.Specialize((*core.Machine)(nil))
		if err != nil {
			continue
		}
		machineBox, err := m.(core.Machine).Box()
		if err != nil {
			return false, err
		}
		ok, err := b.matches(machineBox)
		if err != nil {
			return false, err
		}
		if ok {
			return true, nil
		}
	}
	return false, nil
}

func (b *Box) Machines(index core.TargetIndex) (machines []core.Machine, err error) {
	machines = []core.Machine{}
	targets, err := index.All()
	if err != nil {
		return nil, err
	}
	for _, t := range targets {
		if s, _ := t.State(); s == core.DESTROYED || s == core.NOT_CREATED {
			continue
		}
		m, err := t.Specialize((*core.Machine)(nil))
		if err != nil {
			continue
		}
		machineBox, err := m.(core.Machine).Box()
		if err != nil {
			return nil, err
		}
		if ok, _ := b.matches(machineBox); ok {
			machines = append(machines, m.(core.Machine))
		}
	}
	return
}

func (b *Box) Metadata() (metadata core.BoxMetadata, err error) {
	meta, err := b.loadMetadata()
	if err != nil {
		return nil, err
	}
	return metadata, mapstructure.Decode(meta, &metadata)
}

func (b *Box) BoxMetadata() (metadata map[string]interface{}, err error) {
	return b.box.Metadata.AsMap(), nil
}

func (b *Box) MetadataURL() (url string, err error) {
	return b.box.MetadataUrl, nil
}

func (b *Box) Name() (name string, err error) {
	return b.box.Name, nil
}

func (b *Box) Provider() (name string, err error) {
	return b.box.Provider, nil
}

// This repackages this box and outputs it to the given path.
func (b *Box) Repackage(outputPath path.Path) (err error) {
	b.logger.Trace("repackaging box", b.box.Name,
		"to", outputPath)

	tarFile, err := os.Create(outputPath.String())
	if err != nil {
		return err
	}
	defer tarFile.Close()
	tw := tar.NewWriter(tarFile)
	defer tw.Close()

	err = filepath.Walk(b.box.Directory, func(path string, info os.FileInfo, err error) error {
		header, err := tar.FileInfoHeader(info, path)
		if err != nil {
			return err
		}
		header.Name = filepath.ToSlash(path)
		if err := tw.WriteHeader(header); err != nil {
			return err
		}
		if !info.IsDir() {
			data, err := os.Open(path)
			if err != nil {
				return err
			}
			defer data.Close()
			if _, err := io.Copy(tw, data); err != nil {
				return err
			}
		}
		return nil
	})
	if err != nil {
		return err
	}

	return
}

func (b *Box) Version() (version string, err error) {
	return b.box.Version, nil
}

func (b *Box) Compare(box core.Box) (int, error) {
	name, err := box.Name()
	if err != nil {
		return 0, err
	}
	ver, err := box.Version()
	if err != nil {
		return 0, err
	}
	provider, err := box.Provider()
	if err != nil {
		return 0, err
	}

	if b.box.Name == name && b.box.Provider == provider {
		boxVersion, err := version.NewVersion(b.box.Version)
		if err != nil {
			return 0, nil
		}
		otherVersion, err := version.NewVersion(ver)
		if err != nil {
			return 0, nil
		}

		res := otherVersion.Compare(boxVersion)
		return res, nil
	}
	return 0, errors.New("Box name and provider does not match, can't compare")
}

func (b *Box) ToProto() *vagrant_server.Box {
	return b.box
}

func (b *Box) Save() error {
	b.m.Lock()
	defer b.m.Unlock()
	b.logger.Trace("saving box to db",
		"box", b.box.Name)

	_, err := b.basis.client.UpsertBox(
		b.basis.ctx,
		&vagrant_server.UpsertBoxRequest{Box: b.box},
	)
	if err != nil {
		b.logger.Trace("filed to save box",
			"box", b.box.Name)
	}
	return err
}

var _ core.Box = (*Box)(nil)
