package mvcc

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"sync"

	"gitlab.com/gitlab-org/gitaly/v18/internal/safe"
	"golang.org/x/sync/errgroup"
)

const (
	// latestManifestPointer is the string used in `prepare` to indicate
	// that the latest manifest pointer should be fetched instead of a
	// specific one. The value of the string does not matter, as long as it
	// does not conflict with a real manifest hash.
	latestManifestPointer = ""
)

// EnvVar is a helper struct to allow for better handling of the
// environment variables returned by the Cache.
type EnvVar struct {
	Name  string
	Value string
}

// String returns the environment  in the format "key=val"
func (e EnvVar) String() string {
	return fmt.Sprintf("%s=%s", e.Name, e.Value)
}

// Cache handles all operations between an RPC and a repository's MVCC
// cache. A Cache instance should be created once per RPC, and it's lifetime
// should be tied to the lifetime of the RPC.
// It allows to warm a cache before Git commands can be executed on the
// repository, and to commit the changes at the end of the RPC.
type Cache interface {
	// Prepare prepares the local MVCC cache so that Git operations can
	// be performed on the repository. Under the hood, it should call
	// object storage, fetch the manifest pointer, and make sure all
	// required artifacts are in the mvcc cache.
	// This is an async call, it should not wait for the cache to be ready
	// before returning.
	Prepare(ctx context.Context)

	// PrepareWithManifest is used when the RPC already contains a manifest hash
	// in the metadata. Example: request coming from Gitaly hooks.
	// Instead of using the latestmanifest pointer, it will prepare the
	// cache to use this manifest hash and use it for the duration
	// of the RPC.
	PrepareWithManifest(ctx context.Context, hash string)

	// WaitUntilReady is a blocking call, and should unblock when the mvcc cache
	// is ready. This is useful to start the process of updating the cache
	// ahead of time, in parallel with other things, so that once the handler
	// is ready to execute Git commands, the cache has already started warming up.
	WaitUntilReady(ctx context.Context) error

	// Commit commits the result of the RPC (only Mutator RPC).
	Commit(ctx context.Context) error

	// Environment returns the list of environment variable to use
	// when invoking Git.
	// Ex:
	// - `GIT_MVCC_MANIFEST`
	// - `GIT_MVCC_MANIFEST_PATH`
	Environment() []EnvVar
}

// LocalCache is an implementation of Cache targeting the repository MVCC cache
// on the local filesystem.
type LocalCache struct {
	// rs is the ObjectStorage used to communicate with persistent storage
	// to fetch the manifests and Git artifacts
	rs ObjectStorage

	// repositoryID is the repository ID for the repository related to the
	// current instance of the MVCCCache. While this might be a relative
	// path (i.e.: @hashed/ab/cd/abcd...), it is purposely  called repository
	// **ID** because it could theoretically be any kind of IDs. The concept
	// of relative path is foreign to the cache implementation.
	repositoryID string

	// This the runtime directory of Gitaly. This is where sockets and other Gitaly related files
	// are created during the lifetime of the Gitaly process. This is where we will also create the
	// manifests file for writes RPC.
	runtimeDir string

	// storagePath is the path where to find the repository on disk
	storagePath string

	// environments holds the environment variables that must be passed down
	// to the Git commands executing on this repository for the proper mvcc
	// manifest top be used.
	environments map[string]string

	// readOnly tells if the RPC related to this cache instance is a read-only
	// RPC or not.
	readOnly bool

	// prepareDone is used to synchronize between the prepare phase and the ready phase.
	// This channel gets closed one cache warming is done.
	prepareDone chan struct{}

	// readyCh is closed once the preparation phase is done. It allows to wait for the
	// prepare phase to finish, so that we can capture the error into prepareErr.
	readyDone chan struct{}

	// prepareErr is the error returned by `prepare()`. We cache this value so that
	// subsequent call to `WaitForReady` get the same value everytime. This insures
	// consistent behavior when called multiple time.
	prepareErr error

	// prepareOnce makes sure the cache is only warmed once.
	prepareOnce sync.Once

	// readyOnce insures that, if `prepare` returns an error, multiple calls to
	// WaitForReady returns that same error. Unless of course the context is canceled.
	// In that case it returns the context error.
	readyOnce sync.Once

	// baseHash is the manifest pointer resolved at the start of a mutator RPC. It is
	// the base against which the commit-time compare-and-swap is performed.
	baseHash string
	// baseManifest is the parsed manifest referenced by baseHash. It is used to
	// compute which artifacts a mutator RPC newly produced.
	baseManifest *Manifest
	// committed guards Commit so the canonical manifest pointer is advanced at most
	// once per RPC.
	committed bool
}

// Interface compliance check.
var _ Cache = &LocalCache{}

// NewLocalCache returns a new MVCCCache pointer.
func NewLocalCache(rs ObjectStorage, runtimeDir string, storagePath string, repositoryID string, readOnly bool) *LocalCache {
	return &LocalCache{
		rs:           rs,
		repositoryID: repositoryID,
		runtimeDir:   runtimeDir,
		storagePath:  storagePath,
		environments: make(map[string]string),
		readOnly:     readOnly,
		readyDone:    make(chan struct{}),
		prepareDone:  make(chan struct{}),
	}
}

// Prepare calls PrepareWithManifest under the hood with an empty manifest hash, which semantically
// means to warm the cache using the current manifest pointer.
func (m *LocalCache) Prepare(ctx context.Context) {
	m.PrepareWithManifest(ctx, latestManifestPointer)
}

// PrepareWithManifest kicks off cache warming in the background and returns immediately. The result
// of the warm-up (nil on success, or the first error encountered) is delivered to the ready channel,
// which  WaitUntilReady consumes.
// Repeated calls are no-ops: the work runs at most once per cache instance.
// The hash parameter must be the manifest hash to use for cache warming.
func (m *LocalCache) PrepareWithManifest(ctx context.Context, hash string) {
	m.prepareOnce.Do(func() {
		go func() {
			m.prepareErr = m.prepare(ctx, hash)
			close(m.prepareDone)
		}()
	})
}

// WaitUntilReady blocks until one of those two conditions occur:
// 1. The context is canceled
// 2. The ready phase is done.
//
// If the ready phase is done, the same error returned in the prepare
// phase is returned every time. This makes it safe to call this function
// multiple time. Unless the context gets canceled, the same error as returned
// by the prepare phase will be returned every single time.
func (m *LocalCache) WaitUntilReady(ctx context.Context) error {
	m.readyOnce.Do(func() {
		go func() {
			<-m.prepareDone
			close(m.readyDone)
		}()
	})

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-m.readyDone:
		return m.prepareErr
	}
}

// Commit publishes any MVCC artifacts that have been produced by the LocalCache, and
// updates the repository to point to the latest manifest.
func (m *LocalCache) Commit(ctx context.Context) error {
	if m.readOnly {
		return nil
	}

	if m.ManifestPath() == "" {
		return fmt.Errorf("empty GIT_MVCC_MANIFEST_PATH")
	}

	// Assert the at-most-once property: advancing the canonical pointer more than
	// once for a single RPC would result in a torn write.
	if m.committed {
		return fmt.Errorf("mvcc: commit called more than once")
	}
	m.committed = true

	newHashBytes, err := os.ReadFile(m.ManifestPath())
	if err != nil {
		return fmt.Errorf("read from manifest path: %w", err)
	}
	newHash := strings.TrimSuffix(string(newHashBytes), "\n")
	if newHash == m.baseHash {
		return nil // no changes, nothing to publish
	}

	// Upload the new artifacts before advancing the pointer so the pointer never
	// references an artifact that is not yet durable.
	if err := m.putNewArtifacts(ctx, newHash); err != nil {
		return fmt.Errorf("put new artifacts: %w", err)
	}

	// Atomically advance the canonical manifest pointer.
	return m.rs.Commit(ctx, m.repositoryID, m.baseHash, newHash)
}

// Cleanup removes temporary resources created by the LocalCache.
func (m *LocalCache) Cleanup() {
	if path := m.ManifestPath(); path != "" {
		_ = os.Remove(path)
	}
}

// Environment returns the list of environment variables that must be passed to
// each Git commands executed on the repository when using this MVCC cache.
func (m *LocalCache) Environment() []EnvVar {
	var result []EnvVar
	for k, v := range m.environments {
		result = append(result, EnvVar{Name: k, Value: v})
	}
	return result
}

// ManifestHash is a helper function to retrieve the manifest hash
// used for a read-only RPC. It returns an empty string when not set
// or when the RPC is a mutator one. This function is not part of the
// Cache interface, and is mostly used for testing.
func (m *LocalCache) ManifestHash() string {
	return m.environments["GIT_MVCC_MANIFEST"]
}

// ManifestPath is a helper function to retrieve the manifest path
// used for a mutator RPC. It returns an empty string when not set
// or when the RPC is an accessor one. This function is not part of the
// Cache interface, and is mostly used for testing.
func (m *LocalCache) ManifestPath() string {
	return m.environments["GIT_MVCC_MANIFEST_PATH"]
}

// prepare resolves the manifest pointer, then downloads every reftable it
// references from durable storage into the local cache directory.
func (m *LocalCache) prepare(ctx context.Context, hash string) (err error) {
	// An empty hash means to fetch the latest pointer.
	if hash == latestManifestPointer {
		// Read the manifest pointer from the RemoteStorage
		rawPointer, err := m.rs.ReadManifestPointer(ctx, m.repositoryID)
		if err != nil {
			return fmt.Errorf("read manifest pointer: %w", err)
		}
		hash = rawPointer
	}

	manifest, err := m.prepareManifestFile(ctx, hash)
	if err != nil {
		return fmt.Errorf("prepare manifest file: %w", err)
	}

	// Warm the cache. We only care about reftables for now. Objects will
	// be taken care of at a later stage.
	err = m.prepareArtifacts(ctx, manifest.RefPaths())
	if err != nil {
		return fmt.Errorf("prepare artifacts: %w", err)
	}

	// Prepare environment variables
	if m.readOnly {
		m.environments["GIT_MVCC_MANIFEST"] = hash
	} else {
		// For mutator RPCs, we create a temp manifest pointer file that points to the manifest file
		tmpManifestFileDir := filepath.Join(m.runtimeDir, mvccCacheDir, m.repositoryID)
		err = os.MkdirAll(tmpManifestFileDir, 0o755)
		if err != nil {
			return fmt.Errorf("make manifest temp dir: %w", err)
		}

		tmpManifestFile, err := os.CreateTemp(tmpManifestFileDir, "manifest-*")
		if err != nil {
			return fmt.Errorf("create temp manifest file: %w", err)
		}
		defer func() { _ = tmpManifestFile.Close() }()
		if _, err := tmpManifestFile.WriteString(hash); err != nil {
			return fmt.Errorf("write temp manifest file: %w", err)
		}
		m.baseHash = hash
		m.baseManifest = manifest
		m.environments["GIT_MVCC_MANIFEST_PATH"] = tmpManifestFile.Name()
	}

	return nil
}

// prepareManifestFile will read the manifest file defined by `hash` and return its parsed form.
// If the manifest file already exist on the local filesystem, the file is read from the filesystem.
// Else, the manifest file is fetched from ObjectStorage and then created on the local filesystem for future use.
func (m *LocalCache) prepareManifestFile(ctx context.Context, hash string) (manifest *Manifest, err error) {
	// Construct the manifest path
	manifestFilePath := filepath.Join(m.storagePath, m.repositoryID, mvccCacheDir, "manifests", hash)

	// Verify if it already exists on the local filesystem or not
	existLocally, err := fileExist(manifestFilePath)
	if err != nil {
		return nil, fmt.Errorf("check if file exists: %w", err)
	}

	if !existLocally {
		// If it does not exist, read content from ObjectStorage
		manifestReader, err := m.rs.ReadManifest(ctx, m.repositoryID, hash)
		if err != nil {
			return nil, fmt.Errorf("read manifest file: %w", err)
		}

		defer func() {
			closeErr := manifestReader.Close()
			err = errors.Join(err, closeErr)
		}()

		// Create the manifest file on the local file system atomically. This is a protection against
		// potential concurrent requests trying to create the same manifest file.
		if err = createFileAtomic(ctx, manifestFilePath, manifestReader); err != nil {
			return nil, fmt.Errorf("write manifest: %w", err)
		}
	}

	// Now that it exist on disk, let's open the file.
	manifestFile, err := os.Open(manifestFilePath)
	if err != nil {
		return nil, fmt.Errorf("opening manifest %s: %w", manifestFilePath, err)
	}

	// Read the content in memory. These files should not be that large.
	// The Manifest parsing logic needs the whole file at once to parse it.
	manifestContent := bytes.Buffer{}
	_, err = manifestContent.ReadFrom(manifestFile)
	if err != nil {
		return nil, fmt.Errorf("reading manifest: %w", err)
	}

	// Return the parsed manifest
	return ParseManifest(manifestContent.Bytes())
}

func (m *LocalCache) prepareArtifacts(ctx context.Context, artifacts []string) error {
	const numWorkers = 5

	// errgroup gives us a derived context that is canceled as soon as any worker
	// returns an error, which unblocks the feeder below and the other workers.
	g, ctx := errgroup.WithContext(ctx)

	// Feed each artifact into a channel consumed by all workers below. The
	// feeder stops early if the context is canceled (error or RPC cancellation).
	feedCh := make(chan string)
	g.Go(func() error {
		defer close(feedCh)
		for _, artifact := range artifacts {
			select {
			case <-ctx.Done():
				return ctx.Err()
			case feedCh <- artifact:
			}
		}
		return nil
	})

	for i := 0; i < numWorkers; i++ {
		g.Go(func() error {
			for artifact := range feedCh {
				if err := ctx.Err(); err != nil {
					return err
				}

				artifactLocalPath := filepath.Join(m.storagePath, m.repositoryID, mvccCacheDir, artifact)
				exist, err := fileExist(artifactLocalPath)
				if err != nil {
					return fmt.Errorf("check if file %s exist: %w", artifactLocalPath, err)
				}

				// If the file already exists, there is nothing else to do.
				if exist {
					continue
				}

				reader, err := m.rs.ReadArtifact(ctx, m.repositoryID, artifact)
				if err != nil {
					return fmt.Errorf("read artifact %q: %w", artifact, err)
				}

				if err = createFileAtomic(ctx, artifactLocalPath, reader); err != nil {
					return fmt.Errorf("write artifact %q to disk: %w", artifact, err)
				}
			}
			return nil
		})
	}

	return g.Wait()
}

func (m *LocalCache) putNewArtifacts(ctx context.Context, newHash string) error {
	manifestFile, err := os.Open(filepath.Join(m.storagePath, m.repositoryID, mvccCacheDir, manifestDirectory, newHash))
	if err != nil {
		return fmt.Errorf("opening manifest: %w", err)
	}
	defer func() {
		_ = manifestFile.Close()
	}()
	manifestContent := bytes.Buffer{}
	_, err = manifestContent.ReadFrom(manifestFile)
	if err != nil {
		return fmt.Errorf("reading manifest: %w", err)
	}
	newManifest, err := ParseManifest(manifestContent.Bytes())
	if err != nil {
		return fmt.Errorf("parse new manifest: %w", err)
	}
	baseSet := make(map[string]struct{}, len(m.baseManifest.Paths))
	for _, p := range m.baseManifest.Paths {
		baseSet[p] = struct{}{}
	}
	newArtifacts := make([]string, 0, len(newManifest.Paths))
	manifestKey := filepath.Join(manifestDirectory, newHash)
	newArtifacts = append(newArtifacts, manifestKey)
	for _, p := range newManifest.Paths {
		if _, ok := baseSet[p]; !ok {
			newArtifacts = append(newArtifacts, p)
		}
	}
	for _, p := range newArtifacts {
		localPath := filepath.Join(m.storagePath, m.repositoryID, mvccCacheDir, p)
		f, err := os.Open(localPath)
		if err != nil {
			return fmt.Errorf("read artifact before uploading: %w", err)
		}
		if err := m.rs.PutArtifact(ctx, m.repositoryID, p, f); err != nil {
			_ = f.Close()
			return fmt.Errorf("upload artifact %s: %w", p, err)
		}
		_ = f.Close()
	}

	return nil
}

func fileExist(path string) (bool, error) {
	_, err := os.Stat(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return false, nil
		}
		return false, fmt.Errorf("stat manifest: %w", err)
	}
	return true, nil
}

func createFileAtomic(ctx context.Context, path string, content io.Reader) error {
	w, err := safe.NewFileWriter(path)
	if err != nil {
		return err
	}

	defer func() { _ = w.Close() }()

	if _, err := io.Copy(w, content); err != nil { // w implements io.Writer
		return err
	}

	// Atomically rename temp file -> absPath.
	if err := w.Commit(ctx); err != nil {
		return err
	}
	return nil
}
