package updater

import (
	"errors"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"

	semver "github.com/hashicorp/go-version"

	"github.com/safing/jess/filesig"
	"github.com/safing/portbase/log"
	"github.com/safing/portbase/utils"
)

var devVersion *semver.Version

func init() {
	var err error
	devVersion, err = semver.NewVersion("0")
	if err != nil {
		panic(err)
	}
}

// Resource represents a resource (via an identifier) and multiple file versions.
type Resource struct {
	sync.Mutex
	registry *ResourceRegistry
	notifier *notifier

	// Identifier is the unique identifier for that resource.
	// It forms a file path using a forward-slash as the
	// path separator.
	Identifier string

	// Versions holds all available resource versions.
	Versions []*ResourceVersion

	// ActiveVersion is the last version of the resource
	// that someone requested using GetFile().
	ActiveVersion *ResourceVersion

	// SelectedVersion is newest, selectable version of
	// that resource that is available. A version
	// is selectable if it's not blacklisted by the user.
	// Note that it's not guaranteed that the selected version
	// is available locally. In that case, GetFile will attempt
	// to download the latest version from the updates servers
	// specified in the resource registry.
	SelectedVersion *ResourceVersion

	// VerificationOptions holds the verification options for this resource.
	VerificationOptions *VerificationOptions

	// Index holds a reference to the index this resource was last defined in.
	// Will be nil if resource was only found on disk.
	Index *Index
}

// ResourceVersion represents a single version of a resource.
type ResourceVersion struct {
	resource *Resource

	// VersionNumber is the string representation of the resource
	// version.
	VersionNumber string
	semVer        *semver.Version

	// Available indicates if this version is available locally.
	Available bool

	// SigAvailable indicates if the signature of this version is available locally.
	SigAvailable bool

	// CurrentRelease indicates that this is the current release that should be
	// selected, if possible.
	CurrentRelease bool

	// PreRelease indicates that this version is pre-release.
	PreRelease bool

	// Blacklisted may be set to true if this version should
	// be skipped and not used. This is useful if the version
	// is known to be broken.
	Blacklisted bool
}

func (rv *ResourceVersion) String() string {
	return rv.VersionNumber
}

// SemVer returns the semantic version of the resource.
func (rv *ResourceVersion) SemVer() *semver.Version {
	return rv.semVer
}

// EqualsVersion normalizes the given version and checks equality with semver.
func (rv *ResourceVersion) EqualsVersion(version string) bool {
	cmpSemVer, err := semver.NewVersion(version)
	if err != nil {
		return false
	}

	return rv.semVer.Equal(cmpSemVer)
}

// isSelectable returns true if the version represented by rv is selectable.
// A version is selectable if it's not blacklisted and either already locally
// available or ready to be downloaded.
func (rv *ResourceVersion) isSelectable() bool {
	switch {
	case rv.Blacklisted:
		// Should not be used.
		return false
	case rv.Available:
		// Is available locally, use!
		return true
	case !rv.resource.registry.Online:
		// Cannot download, because registry is set to offline.
		return false
	case rv.resource.Index == nil:
		// Cannot download, because resource is not part of an index.
		return false
	case !rv.resource.Index.AutoDownload:
		// Cannot download, because index may not automatically download.
		return false
	default:
		// Is not available locally, but we are allowed to download it on request!
		return true
	}
}

// isBetaVersionNumber checks if rv is marked as a beta version by checking
// the version string. It does not honor the BetaRelease field of rv!
func (rv *ResourceVersion) isBetaVersionNumber() bool { //nolint:unused
	// "b" suffix check if for backwards compatibility
	// new versions should use the pre-release suffix as
	// declared by https://semver.org
	// i.e. 1.2.3-beta
	switch rv.semVer.Prerelease() {
	case "b", "beta":
		return true
	default:
		return false
	}
}

// Export makes a copy of the resource with only the exposed information.
// Attributes are copied and safe to access.
// Any ResourceVersion must not be modified.
func (res *Resource) Export() *Resource {
	res.Lock()
	defer res.Unlock()

	// Copy attibutes.
	export := &Resource{
		Identifier:      res.Identifier,
		Versions:        make([]*ResourceVersion, len(res.Versions)),
		ActiveVersion:   res.ActiveVersion,
		SelectedVersion: res.SelectedVersion,
	}
	// Copy Versions slice.
	copy(export.Versions, res.Versions)

	return export
}

// Len is the number of elements in the collection.
// It implements sort.Interface for ResourceVersion.
func (res *Resource) Len() int {
	return len(res.Versions)
}

// Less reports whether the element with index i should
// sort before the element with index j.
// It implements sort.Interface for ResourceVersions.
func (res *Resource) Less(i, j int) bool {
	return res.Versions[i].semVer.GreaterThan(res.Versions[j].semVer)
}

// Swap swaps the elements with indexes i and j.
// It implements sort.Interface for ResourceVersions.
func (res *Resource) Swap(i, j int) {
	res.Versions[i], res.Versions[j] = res.Versions[j], res.Versions[i]
}

// available returns whether any version of the resource is available.
func (res *Resource) available() bool {
	for _, rv := range res.Versions {
		if rv.Available {
			return true
		}
	}
	return false
}

// inUse returns true if the resource is currently in use.
func (res *Resource) inUse() bool {
	return res.ActiveVersion != nil
}

// AnyVersionAvailable returns true if any version of
// res is locally available.
func (res *Resource) AnyVersionAvailable() bool {
	res.Lock()
	defer res.Unlock()

	return res.available()
}

func (reg *ResourceRegistry) newResource(identifier string) *Resource {
	return &Resource{
		registry:            reg,
		Identifier:          identifier,
		Versions:            make([]*ResourceVersion, 0, 1),
		VerificationOptions: reg.GetVerificationOptions(identifier),
	}
}

// AddVersion adds a resource version to a resource.
func (res *Resource) AddVersion(version string, available, currentRelease, preRelease bool) error {
	res.Lock()
	defer res.Unlock()

	// reset current release flags
	if currentRelease {
		for _, rv := range res.Versions {
			rv.CurrentRelease = false
		}
	}

	var rv *ResourceVersion
	// check for existing version
	for _, possibleMatch := range res.Versions {
		if possibleMatch.VersionNumber == version {
			rv = possibleMatch
			break
		}
	}

	// create new version if none found
	if rv == nil {
		// parse to semver
		sv, err := semver.NewVersion(version)
		if err != nil {
			return err
		}

		rv = &ResourceVersion{
			resource:      res,
			VersionNumber: sv.String(), // Use normalized version.
			semVer:        sv,
		}
		res.Versions = append(res.Versions, rv)
	}

	// set flags
	if available {
		rv.Available = true

		// If available and signatures are enabled for this resource, check if the
		// signature is available.
		if res.VerificationOptions != nil && utils.PathExists(rv.storageSigPath()) {
			rv.SigAvailable = true
		}
	}
	if currentRelease {
		rv.CurrentRelease = true
	}
	if preRelease || rv.semVer.Prerelease() != "" {
		rv.PreRelease = true
	}

	return nil
}

// GetFile returns the selected version as a *File.
func (res *Resource) GetFile() *File {
	res.Lock()
	defer res.Unlock()

	// check for notifier
	if res.notifier == nil {
		// create new notifier
		res.notifier = newNotifier()
	}

	// check if version is selected
	if res.SelectedVersion == nil {
		res.selectVersion()
	}

	// create file
	return &File{
		resource:      res,
		version:       res.SelectedVersion,
		notifier:      res.notifier,
		versionedPath: res.SelectedVersion.versionedPath(),
		storagePath:   res.SelectedVersion.storagePath(),
	}
}

//nolint:gocognit // function already kept as simple as possible
func (res *Resource) selectVersion() {
	sort.Sort(res)

	// export after we finish
	var fallback bool
	defer func() {
		if fallback {
			log.Tracef("updater: selected version %s (as fallback) for resource %s", res.SelectedVersion, res.Identifier)
		} else {
			log.Debugf("updater: selected version %s for resource %s", res.SelectedVersion, res.Identifier)
		}

		if res.inUse() &&
			res.SelectedVersion != res.ActiveVersion && // new selected version does not match previously selected version
			res.notifier != nil {

			res.notifier.markAsUpgradeable()
			res.notifier = nil

			log.Debugf("updater: active version of %s is %s, update available", res.Identifier, res.ActiveVersion.VersionNumber)
		}
	}()

	if len(res.Versions) == 0 {
		// TODO: find better way to deal with an empty version slice (which should not happen)
		res.SelectedVersion = nil
		return
	}

	// Target selection

	// 1) Dev release if dev mode is active and ignore blacklisting
	if res.registry.DevMode {
		// Get last version, as this will be v0.0.0, if available.
		rv := res.Versions[len(res.Versions)-1]
		// Check if it's v0.0.0.
		if rv.semVer.Equal(devVersion) && rv.Available {
			res.SelectedVersion = rv
			return
		}
	}

	// 2) Find the current release. This may be also be a pre-release.
	for _, rv := range res.Versions {
		if rv.CurrentRelease {
			if rv.isSelectable() {
				res.SelectedVersion = rv
				return
			}
			// There can only be once current release,
			// so we can abort after finding one.
			break
		}
	}

	// 3) If UsePreReleases is set, find any newest version.
	if res.registry.UsePreReleases {
		for _, rv := range res.Versions {
			if rv.isSelectable() {
				res.SelectedVersion = rv
				return
			}
		}
	}

	// 4) Find the newest stable version.
	for _, rv := range res.Versions {
		if !rv.PreRelease && rv.isSelectable() {
			res.SelectedVersion = rv
			return
		}
	}

	// 5) Default to newest.
	res.SelectedVersion = res.Versions[0]
	fallback = true
}

// Blacklist blacklists the specified version and selects a new version.
func (res *Resource) Blacklist(version string) error {
	res.Lock()
	defer res.Unlock()

	// count available and valid versions
	valid := 0
	for _, rv := range res.Versions {
		if rv.semVer.Equal(devVersion) {
			continue // ignore dev versions
		}
		if !rv.Blacklisted {
			valid++
		}
	}
	if valid <= 1 {
		return errors.New("cannot blacklist last version") // last one, cannot blacklist!
	}

	// find version and blacklist
	for _, rv := range res.Versions {
		if rv.VersionNumber == version {
			// blacklist and update
			rv.Blacklisted = true
			res.selectVersion()
			return nil
		}
	}

	return errors.New("could not find version")
}

// Purge deletes old updates, retaining a certain amount, specified by
// the keep parameter. Purge will always keep at least 2 versions so
// specifying a smaller keep value will have no effect.
func (res *Resource) Purge(keepExtra int) { //nolint:gocognit
	res.Lock()
	defer res.Unlock()

	// If there is any blacklisted version within the resource, pause purging.
	// In this case we may need extra available versions beyond what would be
	// available after purging.
	for _, rv := range res.Versions {
		if rv.Blacklisted {
			log.Debugf(
				"%s: pausing purging of resource %s, as it contains blacklisted items",
				res.registry.Name,
				rv.resource.Identifier,
			)
			return
		}
	}

	// Safeguard the amount of extra version to keep.
	if keepExtra < 2 {
		keepExtra = 2
	}

	// Search for purge boundary.
	var purgeBoundary int
	var skippedActiveVersion bool
	var skippedSelectedVersion bool
	var skippedStableVersion bool
boundarySearch:
	for i, rv := range res.Versions {
		// Check if required versions are already skipped.
		switch {
		case !skippedActiveVersion && res.ActiveVersion != nil:
			// Skip versions until the active version, if it's set.
		case !skippedSelectedVersion && res.SelectedVersion != nil:
			// Skip versions until the selected version, if it's set.
		case !skippedStableVersion:
			// Skip versions until the stable version.
		default:
			// All required version skipped, set purge boundary.
			purgeBoundary = i + keepExtra
			break boundarySearch
		}

		// Check if current instance is a required version.
		if rv == res.ActiveVersion {
			skippedActiveVersion = true
		}
		if rv == res.SelectedVersion {
			skippedSelectedVersion = true
		}
		if !rv.PreRelease {
			skippedStableVersion = true
		}
	}

	// Check if there is anything to purge at all.
	if purgeBoundary <= keepExtra || purgeBoundary >= len(res.Versions) {
		return
	}

	// Purge everything beyond the purge boundary.
	for _, rv := range res.Versions[purgeBoundary:] {
		// Only remove if resource file is actually available.
		if !rv.Available {
			continue
		}

		// Remove resource file.
		storagePath := rv.storagePath()
		err := os.Remove(storagePath)
		if err != nil {
			if !errors.Is(err, fs.ErrNotExist) {
				log.Warningf("%s: failed to purge resource %s v%s: %s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber, err)
			}
		} else {
			log.Tracef("%s: purged resource %s v%s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber)
		}

		// Remove resource signature file.
		err = os.Remove(rv.storageSigPath())
		if err != nil {
			if !errors.Is(err, fs.ErrNotExist) {
				log.Warningf("%s: failed to purge resource signature %s v%s: %s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber, err)
			}
		} else {
			log.Tracef("%s: purged resource signature %s v%s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber)
		}

		// Remove unpacked version of resource.
		ext := filepath.Ext(storagePath)
		if ext == "" {
			// Nothing to do if file does not have an extension.
			continue
		}
		unpackedPath := strings.TrimSuffix(storagePath, ext)

		// Remove if it exists, or an error occurs on access.
		_, err = os.Stat(unpackedPath)
		if err == nil || !errors.Is(err, fs.ErrNotExist) {
			err = os.Remove(unpackedPath)
			if err != nil {
				log.Warningf("%s: failed to purge unpacked resource %s v%s: %s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber, err)
			} else {
				log.Tracef("%s: purged unpacked resource %s v%s", res.registry.Name, rv.resource.Identifier, rv.VersionNumber)
			}
		}
	}

	// remove entries of deleted files
	res.Versions = res.Versions[purgeBoundary:]
}

// SigningMetadata returns the metadata to be included in signatures.
func (rv *ResourceVersion) SigningMetadata() map[string]string {
	return map[string]string{
		"id":      rv.resource.Identifier,
		"version": rv.VersionNumber,
	}
}

// GetFile returns the version as a *File.
// It locks the resource for doing so.
func (rv *ResourceVersion) GetFile() *File {
	rv.resource.Lock()
	defer rv.resource.Unlock()

	// check for notifier
	if rv.resource.notifier == nil {
		// create new notifier
		rv.resource.notifier = newNotifier()
	}

	// create file
	return &File{
		resource:      rv.resource,
		version:       rv,
		notifier:      rv.resource.notifier,
		versionedPath: rv.versionedPath(),
		storagePath:   rv.storagePath(),
	}
}

// versionedPath returns the versioned identifier.
func (rv *ResourceVersion) versionedPath() string {
	return GetVersionedPath(rv.resource.Identifier, rv.VersionNumber)
}

// versionedSigPath returns the versioned identifier of the file signature.
func (rv *ResourceVersion) versionedSigPath() string {
	return GetVersionedPath(rv.resource.Identifier, rv.VersionNumber) + filesig.Extension
}

// storagePath returns the absolute storage path.
func (rv *ResourceVersion) storagePath() string {
	return filepath.Join(rv.resource.registry.storageDir.Path, filepath.FromSlash(rv.versionedPath()))
}

// storageSigPath returns the absolute storage path of the file signature.
func (rv *ResourceVersion) storageSigPath() string {
	return rv.storagePath() + filesig.Extension
}
