package file

import (
	"fmt"

	"github.com/hashicorp/go-multierror"

	"github.com/anchore/stereoscope/pkg/file"
	"github.com/anchore/stereoscope/pkg/image"
)

const (
	// VisibleAnnotationKey is the key used to indicate if the location is visible or not at runtime
	VisibleAnnotationKey = "visible"

	// HiddenAnnotation is the value used to indicate that the location is not visible at runtime because it was deleted
	HiddenAnnotation = "false"

	// VisibleAnnotation is the value used to indicate that the location is visible at runtime
	VisibleAnnotation = "true"
)

// Location represents a path relative to a particular filesystem resolved to a specific file.Reference. This struct is used as a key
// in content fetching to uniquely identify a file relative to a request (the AccessPath).
type Location struct {
	LocationData     `cyclonedx:""`
	LocationMetadata `cyclonedx:""`
}

// LocationData contains the core identifying information for a file location.
type LocationData struct {
	Coordinates `cyclonedx:""` // Empty string here means there is no intermediate property name, e.g. syft:locations:0:path without "coordinates"
	// note: it is IMPORTANT to ignore anything but the coordinates for a Location when considering the ID (hash value)
	// since the coordinates are the minimally correct ID for a location (symlinks should not come into play)

	// AccessPath is the path used to retrieve file contents (which may or may not have hardlinks / symlinks in the path)
	AccessPath string `hash:"ignore" json:"accessPath"`

	// ref is the stereoscope file reference relative to the stereoscope.FileCatalog that has more information about this location.
	ref file.Reference `hash:"ignore"`
}

func (l LocationData) Reference() file.Reference {
	return l.ref
}

// LocationMetadata provides additional contextual information about a file location.
type LocationMetadata struct {
	Annotations map[string]string `json:"annotations,omitempty"` // Arbitrary key-value pairs that can be used to annotate a location
}

func (m *LocationMetadata) merge(other LocationMetadata) error {
	var errs error
	for k, v := range other.Annotations {
		if otherV, ok := m.Annotations[k]; ok {
			if v != otherV {
				err := fmt.Errorf("unable to merge location metadata: conflicting values for key=%q: %q != %q", k, v, otherV)
				errs = multierror.Append(errs, err)
				continue
			}
		}
		m.Annotations[k] = v
	}
	return errs
}

func (l Location) WithAnnotation(key, value string) Location {
	if key == "" || value == "" {
		return l
	}
	if l.Annotations == nil {
		l.Annotations = map[string]string{}
	}
	l.Annotations[key] = value
	return l
}

func (l Location) WithoutAnnotations() Location {
	l.Annotations = map[string]string{}

	return l
}

// NewLocation creates a new Location representing a path without denoting a filesystem or FileCatalog reference.
func NewLocation(realPath string) Location {
	return Location{
		LocationData: LocationData{
			Coordinates: Coordinates{
				RealPath: realPath,
			},
			AccessPath: realPath,
		},
		LocationMetadata: LocationMetadata{
			Annotations: map[string]string{},
		},
	}
}

// NewVirtualLocation creates a new location for a path accessed by a virtual path (a path with a symlink or hardlink somewhere in the path)
func NewVirtualLocation(realPath, accessPath string) Location {
	return Location{
		LocationData: LocationData{
			Coordinates: Coordinates{
				RealPath: realPath,
			},
			AccessPath: accessPath,
		},
		LocationMetadata: LocationMetadata{
			Annotations: map[string]string{},
		}}
}

// NewLocationFromCoordinates creates a new location for the given Coordinates.
func NewLocationFromCoordinates(coordinates Coordinates) Location {
	return Location{
		LocationData: LocationData{
			Coordinates: coordinates,
			AccessPath:  coordinates.RealPath,
		},
		LocationMetadata: LocationMetadata{
			Annotations: map[string]string{},
		}}
}

// NewVirtualLocationFromCoordinates creates a new location for the given Coordinates via a virtual path.
func NewVirtualLocationFromCoordinates(coordinates Coordinates, accessPath string) Location {
	return Location{
		LocationData: LocationData{
			Coordinates: coordinates,
			AccessPath:  accessPath,
		},
		LocationMetadata: LocationMetadata{
			Annotations: map[string]string{},
		}}
}

// NewLocationFromImage creates a new Location representing the given path (extracted from the Reference) relative to the given image.
func NewLocationFromImage(accessPath string, ref file.Reference, img *image.Image) Location {
	layer := img.FileCatalog.Layer(ref)
	return Location{
		LocationData: LocationData{
			Coordinates: Coordinates{
				RealPath:     string(ref.RealPath),
				FileSystemID: layer.Metadata.Digest,
			},
			AccessPath: accessPath,
			ref:        ref,
		},
		LocationMetadata: LocationMetadata{
			Annotations: map[string]string{},
		},
	}
}

// NewVirtualLocationFromImage creates a new Location surfaced at realPath but whose contents and metadata are
// described by the given reference. This is used to present a hardlink as the underlying type it points to (at the
// hardlink's own path) so that image results are in parity with directory results, which cannot distinguish a
// hardlink from a regular file. Note that RealPath (the hardlink's own path) intentionally differs from
// ref.RealPath (the target's path); callers must not assume the two agree for these locations.
func NewVirtualLocationFromImage(realPath, accessPath string, ref file.Reference, img *image.Image) Location {
	// the FileSystemID comes from the target ref's layer. a hardlink and its target are always materialized in the
	// same layer tar (a tar hardlink entry can only reference a file within the same archive), so the target's layer
	// digest is also the hardlink path's layer.
	layer := img.FileCatalog.Layer(ref)
	return Location{
		LocationData: LocationData{
			Coordinates: Coordinates{
				RealPath:     realPath,
				FileSystemID: layer.Metadata.Digest,
			},
			AccessPath: accessPath,
			ref:        ref,
		},
		LocationMetadata: LocationMetadata{
			Annotations: map[string]string{},
		},
	}
}

// NewLocationFromDirectory creates a new Location representing the given path (extracted from the Reference) relative to the given directory.
func NewLocationFromDirectory(responsePath string, fd string, ref file.Reference) Location {
	return Location{
		LocationData: LocationData{
			Coordinates: Coordinates{
				RealPath:     responsePath,
				FileSystemID: fd,
			},
			AccessPath: responsePath,
			ref:        ref,
		},
		LocationMetadata: LocationMetadata{
			Annotations: map[string]string{},
		},
	}
}

// NewVirtualLocationFromDirectory creates a new Location representing the given path (extracted from the Reference) relative to the given directory with a separate virtual access path.
func NewVirtualLocationFromDirectory(responsePath, responseAccessPath string, ref file.Reference) Location {
	return Location{
		LocationData: LocationData{
			Coordinates: Coordinates{
				RealPath: responsePath,
			},
			AccessPath: responseAccessPath,
			ref:        ref,
		},
		LocationMetadata: LocationMetadata{
			Annotations: map[string]string{},
		},
	}
}

func (l Location) Path() string {
	if l.AccessPath != "" {
		return l.AccessPath
	}
	return l.RealPath
}

func (l Location) String() string {
	str := ""
	if l.ref.ID() != 0 {
		str += fmt.Sprintf("id=%d ", l.ref.ID())
	}

	str += fmt.Sprintf("RealPath=%q", l.RealPath)

	if l.AccessPath != "" && l.AccessPath != l.RealPath {
		str += fmt.Sprintf(" AccessPath=%q", l.AccessPath)
	}

	if l.FileSystemID != "" {
		str += fmt.Sprintf(" Layer=%q", l.FileSystemID)
	}
	return fmt.Sprintf("Location<%s>", str)
}

func (l Location) Equals(other Location) bool {
	return l.RealPath == other.RealPath &&
		l.AccessPath == other.AccessPath &&
		l.FileSystemID == other.FileSystemID
}
