// Copyright 2016-2018, Pulumi Corporation.
//
// 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 asset

import (
	"bytes"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"

	"github.com/pulumi/pulumi/sdk/v3/go/common/resource/sig"
	"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
	"github.com/pulumi/pulumi/sdk/v3/go/common/util/httputil"
)

// Asset is a serialized asset reference. It is a union: thus, at most one of its fields will be non-nil. Several helper
// routines exist as members in order to easily interact with the assets referenced by an instance of this type.
type Asset struct {
	// Sig is the unique asset type signature (see properties.go).
	Sig string `json:"4dabf18193072939515e22adb298388d" yaml:"4dabf18193072939515e22adb298388d"`
	// Hash is the SHA256 hash of the asset's contents.
	Hash string `json:"hash,omitempty" yaml:"hash,omitempty"`
	// Text is set to a non-empty value for textual assets.
	Text string `json:"text,omitempty" yaml:"text,omitempty"`
	// Path will contain a non-empty path to the file on the current filesystem for file assets.
	Path string `json:"path,omitempty" yaml:"path,omitempty"`
	// URI will contain a non-empty URI (file://, http://, https://, or custom) for URI-backed assets.
	URI string `json:"uri,omitempty" yaml:"uri,omitempty"`
}

const (
	AssetSig          = sig.AssetSig
	AssetHashProperty = "hash" // the dynamic property for an asset's hash.
	AssetTextProperty = "text" // the dynamic property for an asset's text.
	AssetPathProperty = "path" // the dynamic property for an asset's path.
	AssetURIProperty  = "uri"  // the dynamic property for an asset's URI.
)

// FromText produces a new asset and its corresponding SHA256 hash from the given text.
func FromText(text string) (*Asset, error) {
	a := &Asset{Sig: AssetSig, Text: text}
	// Special case the empty string otherwise EnsureHash will fail.
	if text == "" {
		a.Hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
	}
	err := a.EnsureHash()
	return a, err
}

// FromPath produces a new asset and its corresponding SHA256 hash from the given filesystem path.
func FromPath(path string) (*Asset, error) {
	a := &Asset{Sig: AssetSig, Path: path}
	err := a.EnsureHash()
	return a, err
}

// FromPathWithWD produces a new asset and its corresponding SHA256 hash from the given filesystem path.
func FromPathWithWD(path string, wd string) (*Asset, error) {
	a := &Asset{Sig: AssetSig, Path: path}
	err := a.EnsureHashWithWD(wd)
	return a, err
}

// FromURI produces a new asset and its corresponding SHA256 hash from the given network URI.
func FromURI(uri string) (*Asset, error) {
	a := &Asset{Sig: AssetSig, URI: uri}
	err := a.EnsureHash()
	return a, err
}

func (a *Asset) IsText() bool {
	if a.IsPath() || a.IsURI() {
		return false
	}
	if a.Text != "" {
		return true
	}
	// We can't easily tell the difference between an Asset that really has the empty string as its text and one that
	// has no text at all. If we have a hash we can check if that's the "zero hash" and if so then we know the text is
	// just empty. If the hash does not equal the empty hash then we know this is a _placeholder_ asset where the text is
	// just currently not known. If we don't have a hash then we can't tell the difference and assume it's just empty.
	if a.Hash == "" || a.Hash == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" {
		return true
	}
	return false
}
func (a *Asset) IsPath() bool { return a.Path != "" }
func (a *Asset) IsURI() bool  { return a.URI != "" }

func (a *Asset) GetText() (string, bool) {
	if a.IsText() {
		return a.Text, true
	}
	return "", false
}

func (a *Asset) GetPath() (string, bool) {
	if a.IsPath() {
		return a.Path, true
	}
	return "", false
}

func (a *Asset) GetURI() (string, bool) {
	if a.IsURI() {
		return a.URI, true
	}
	return "", false
}

// GetURIURL returns the underlying URI as a parsed URL, provided it is one.  If there was an error parsing the URI, it
// will be returned as a non-nil error object.
func (a *Asset) GetURIURL() (*url.URL, bool, error) {
	if uri, isuri := a.GetURI(); isuri {
		url, err := url.Parse(uri)
		if err != nil {
			return nil, true, err
		}
		return url, true, nil
	}
	return nil, false, nil
}

// Equals returns true if a is value-equal to other. In this case, value equality is determined only by the hash: even
// if the contents of two assets come from different sources, they are treated as equal if their hashes match.
// Similarly, if the contents of two assets come from the same source but the assets have different hashes, the assets
// are not equal.
func (a *Asset) Equals(other *Asset) bool {
	if a == nil {
		return other == nil
	} else if other == nil {
		return false
	}

	// If we can't get a hash for both assets, treat them as differing.
	if err := a.EnsureHash(); err != nil {
		return false
	}
	if err := other.EnsureHash(); err != nil {
		return false
	}
	return a.Hash == other.Hash
}

// Serialize returns a weakly typed map that contains the right signature for serialization purposes.
func (a *Asset) Serialize() map[string]interface{} {
	result := map[string]interface{}{
		sig.Key: AssetSig,
	}
	if a.Hash != "" {
		result[AssetHashProperty] = a.Hash
	}
	if a.Text != "" {
		result[AssetTextProperty] = a.Text
	}
	if a.Path != "" {
		result[AssetPathProperty] = a.Path
	}
	if a.URI != "" {
		result[AssetURIProperty] = a.URI
	}
	return result
}

// DeserializeAsset checks to see if the map contains an asset, using its signature, and if so deserializes it.
func Deserialize(obj map[string]interface{}) (*Asset, bool, error) {
	// If not an asset, return false immediately.
	if obj[sig.Key] != AssetSig {
		return &Asset{}, false, nil
	}

	// Else, deserialize the possible fields.
	var hash string
	if v, has := obj[AssetHashProperty]; has {
		h, ok := v.(string)
		if !ok {
			return &Asset{}, false, fmt.Errorf("unexpected asset hash of type %T", v)
		}
		hash = h
	}
	var text string
	if v, has := obj[AssetTextProperty]; has {
		t, ok := v.(string)
		if !ok {
			return &Asset{}, false, fmt.Errorf("unexpected asset text of type %T", v)
		}
		text = t
	}
	var path string
	if v, has := obj[AssetPathProperty]; has {
		p, ok := v.(string)
		if !ok {
			return &Asset{}, false, fmt.Errorf("unexpected asset path of type %T", v)
		}
		path = p
	}
	var uri string
	if v, has := obj[AssetURIProperty]; has {
		u, ok := v.(string)
		if !ok {
			return &Asset{}, false, fmt.Errorf("unexpected asset URI of type %T", v)
		}
		uri = u
	}

	return &Asset{Sig: AssetSig, Hash: hash, Text: text, Path: path, URI: uri}, true, nil
}

// HasContents indicates whether or not an asset's contents can be read.
func (a *Asset) HasContents() bool {
	return a.IsText() || a.IsPath() || a.IsURI()
}

// Bytes returns the contents of the asset as a byte slice.
func (a *Asset) Bytes() ([]byte, error) {
	// If this is a text asset, just return its bytes directly.
	if text, istext := a.GetText(); istext {
		return []byte(text), nil
	}

	blob, err := a.Read()
	if err != nil {
		return nil, err
	}
	return io.ReadAll(blob)
}

// Read begins reading an asset.
func (a *Asset) Read() (*Blob, error) {
	wd, err := os.Getwd()
	if err != nil {
		return nil, err
	}

	return a.ReadWithWD(wd)
}

// ReadWithWD begins reading an asset.
func (a *Asset) ReadWithWD(wd string) (*Blob, error) {
	if a.IsText() {
		return a.readText()
	} else if a.IsPath() {
		return a.readPath(wd)
	} else if a.IsURI() {
		return a.readURI()
	}
	return nil, errors.New("unrecognized asset type")
}

func (a *Asset) readText() (*Blob, error) {
	text, istext := a.GetText()
	contract.Assertf(istext, "Expected a text-based asset")
	return NewByteBlob([]byte(text)), nil
}

func (a *Asset) readPath(wd string) (*Blob, error) {
	path, ispath := a.GetPath()
	contract.Assertf(ispath, "Expected a path-based asset")

	if !filepath.IsAbs(path) {
		path = filepath.Join(wd, path)
	}

	file, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("failed to open asset file '%v': %w", path, err)
	}

	// Do a quick check to make sure it's a file, so we can fail gracefully if someone passes a directory.
	info, err := file.Stat()
	if err != nil {
		contract.IgnoreClose(file)
		return nil, fmt.Errorf("failed to stat asset file '%v': %w", path, err)
	}
	if info.IsDir() {
		contract.IgnoreClose(file)
		return nil, fmt.Errorf("asset path '%v' is a directory; try using an archive", path)
	}

	blob := &Blob{
		rd: file,
		sz: info.Size(),
	}
	return blob, nil
}

func (a *Asset) readURI() (*Blob, error) {
	url, isURL, err := a.GetURIURL()
	if err != nil {
		return nil, err
	}
	contract.Assertf(isURL, "Expected a URI-based asset")
	switch s := url.Scheme; s {
	case "http", "https":
		resp, err := httputil.GetWithRetry(url.String(), http.DefaultClient)
		if err != nil {
			return nil, err
		}
		return NewReadCloserBlob(resp.Body)
	case "file":
		contract.Assertf(url.User == nil, "file:// URIs cannot have a user: %v", url)
		contract.Assertf(url.RawQuery == "", "file:// URIs cannot have a query string: %v", url)
		contract.Assertf(url.Fragment == "", "file:// URIs cannot have a fragment: %v", url)
		if url.Host != "" && url.Host != "localhost" {
			return nil, fmt.Errorf("file:// host '%v' not supported (only localhost)", url.Host)
		}
		f, err := os.Open(url.Path)
		if err != nil {
			return nil, err
		}
		return NewFileBlob(f)
	default:
		return nil, fmt.Errorf("Unrecognized or unsupported URI scheme: %v", s)
	}
}

// EnsureHash computes the SHA256 hash of the asset's contents and stores it on the object.
func (a *Asset) EnsureHash() error {
	if a.Hash == "" {
		blob, err := a.Read()
		if err != nil {
			return err
		}
		defer contract.IgnoreClose(blob)

		hash := sha256.New()
		n, err := io.Copy(hash, blob)
		if err != nil {
			return err
		}
		if n != blob.Size() {
			return fmt.Errorf("incorrect blob size: expected %v, got %v", blob.Size(), n)
		}
		a.Hash = hex.EncodeToString(hash.Sum(nil))
	}
	return nil
}

// EnsureHash computes the SHA256 hash of the asset's contents and stores it on the object.
func (a *Asset) EnsureHashWithWD(wd string) error {
	if a.Hash == "" {
		blob, err := a.ReadWithWD(wd)
		if err != nil {
			return err
		}
		defer contract.IgnoreClose(blob)

		hash := sha256.New()
		n, err := io.Copy(hash, blob)
		if err != nil {
			return err
		}
		if n != blob.Size() {
			return fmt.Errorf("incorrect blob size: expected %v, got %v", blob.Size(), n)
		}
		a.Hash = hex.EncodeToString(hash.Sum(nil))
	}
	return nil
}

// Blob is a blob that implements ReadCloser and offers Len functionality.
type Blob struct {
	rd io.ReadCloser // an underlying reader.
	sz int64         // the size of the blob.
}

func (blob *Blob) Close() error               { return blob.rd.Close() }
func (blob *Blob) Read(p []byte) (int, error) { return blob.rd.Read(p) }
func (blob *Blob) Size() int64                { return blob.sz }

// NewByteBlob creates a new byte blob.
func NewByteBlob(data []byte) *Blob {
	return &Blob{
		rd: io.NopCloser(bytes.NewReader(data)),
		sz: int64(len(data)),
	}
}

// NewFileBlob creates a new asset blob whose size is known thanks to stat.
func NewFileBlob(f *os.File) (*Blob, error) {
	stat, err := f.Stat()
	if err != nil {
		return nil, err
	}
	return &Blob{
		rd: f,
		sz: stat.Size(),
	}, nil
}

// NewReadCloserBlob turn any old ReadCloser into an Blob, usually by making a copy.
func NewReadCloserBlob(r io.ReadCloser) (*Blob, error) {
	if f, isf := r.(*os.File); isf {
		// If it's a file, we can "fast path" the asset creation without making a copy.
		return NewFileBlob(f)
	}
	// Otherwise, read it all in, and create a blob out of that.
	defer contract.IgnoreClose(r)
	data, err := io.ReadAll(r)
	if err != nil {
		return nil, err
	}
	return NewByteBlob(data), nil
}

func NewRawBlob(r io.ReadCloser, size int64) *Blob {
	return &Blob{rd: r, sz: size}
}