package getter

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/url"
	"os"
	"path/filepath"
	"strings"

	"github.com/gruntwork-io/terragrunt/internal/venv"
	"github.com/gruntwork-io/terragrunt/internal/vfs"
	"github.com/gruntwork-io/terragrunt/pkg/log"
	getter "github.com/hashicorp/go-getter/v2"
	"github.com/opencontainers/go-digest"
	ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
	"oras.land/oras-go/v2/content"
	"oras.land/oras-go/v2/registry"
)

const (
	ociTagQueryKey    = "tag"
	ociDigestQueryKey = "digest"
	// ociDefaultTag is the tag used when the source pins neither a tag nor a
	// digest, matching OpenTofu.
	ociDefaultTag = "latest"
	// ociMaxManifestSize bounds manifest downloads so a registry cannot force
	// an unbounded allocation, matching OpenTofu.
	ociMaxManifestSize = 4 << 20
	// ociLayerSizeWarnThreshold warns before downloading an anomalously large layer.
	ociLayerSizeWarnThreshold = 50 << 20
	// ociMaxLayerSize rejects layer descriptors above this size before fetching.
	ociMaxLayerSize int64 = 512 << 20
	// ociMaxDecompressedSize caps the bytes a module layer may expand to, so a
	// digest-valid archive cannot exhaust the disk.
	ociMaxDecompressedSize int64 = 512 << 20
	// ociMaxDecompressedFiles caps the entries a module layer may expand to, so a
	// digest-valid archive cannot exhaust inodes.
	ociMaxDecompressedFiles = 10000
)

// ErrOCIGetFileUnsupported reports that oci sources always resolve to module
// directories, so single-file downloads are not supported.
var ErrOCIGetFileUnsupported = errors.New("GetFile is not supported for the OCI Getter")

// ErrOCIGetterNotConfigured reports an OCIGetter used without its NewStore
// seam, logger, or filesystem.
var ErrOCIGetterNotConfigured = errors.New(
	"oci getter is not fully configured (missing repository store, logger, or filesystem). " +
		"This is a bug in Terragrunt. Please open an issue on github.com/gruntwork-io/terragrunt",
)

// ErrOCIMissingRegistryDomain reports an oci source without a registry host.
var ErrOCIMissingRegistryDomain = errors.New("oci source is missing a registry domain")

// ErrOCIMissingRepositoryName reports an oci source without a repository path.
var ErrOCIMissingRepositoryName = errors.New("oci source is missing a repository name")

// ErrOCIInvalidRepositoryName reports a repository path that fails OCI reference validation.
var ErrOCIInvalidRepositoryName = errors.New("invalid oci repository name")

// OCIEmbeddedReferenceError reports a docker-style ":tag" or "@digest" suffix
// in an oci repository path, carrying the query-form source to use instead.
type OCIEmbeddedReferenceError struct {
	RepositoryName  string
	SuggestedSource string
}

func (err OCIEmbeddedReferenceError) Error() string {
	return fmt.Sprintf(
		"%s %q: pin the version with a query argument instead: %q",
		ErrOCIInvalidRepositoryName, err.RepositoryName, err.SuggestedSource,
	)
}

// Unwrap keeps [ErrOCIInvalidRepositoryName] matchable on the suffix form.
func (err OCIEmbeddedReferenceError) Unwrap() error {
	return ErrOCIInvalidRepositoryName
}

// ErrOCITagDigestExclusive reports an oci source that pins both a tag and a
// digest; the wording mirrors OpenTofu so one source string fails the same
// way in both tools.
var ErrOCITagDigestExclusive = errors.New(`cannot set both "tag" and "digest" arguments`)

// ErrOCIUnexpectedScheme reports an oci resolver handed a source whose scheme
// is not oci, so no manifest digest can be resolved for it.
var ErrOCIUnexpectedScheme = errors.New("oci resolver received a source with a non-oci scheme")

// OCIReferenceResolutionError reports a failure resolving an oci reference (a
// tag or digest) to a manifest against the registry, wrapping the registry
// error so callers can match on the cause with errors.As.
type OCIReferenceResolutionError struct {
	Err error
	Ref string
}

func (err OCIReferenceResolutionError) Error() string {
	return fmt.Sprintf("resolving OCI reference %q: %s", err.Ref, err.Err)
}

func (err OCIReferenceResolutionError) Unwrap() error { return err.Err }

// OCIUnsupportedQueryParamError reports a query parameter other than tag or
// digest on an oci source.
type OCIUnsupportedQueryParamError struct {
	Param string
}

func (err OCIUnsupportedQueryParamError) Error() string {
	return fmt.Sprintf("unsupported argument %q", err.Param)
}

// OCIDuplicateQueryParamError reports a query parameter given more than once
// on an oci source.
type OCIDuplicateQueryParamError struct {
	Param string
}

func (err OCIDuplicateQueryParamError) Error() string {
	return fmt.Sprintf("argument %q must not be repeated", err.Param)
}

// OCIEmptyQueryParamError reports a query parameter with an empty value on an
// oci source.
type OCIEmptyQueryParamError struct {
	Param string
}

func (err OCIEmptyQueryParamError) Error() string {
	return fmt.Sprintf("argument %q must not be empty", err.Param)
}

// OCIInvalidDigestError reports a digest pin that does not parse as an OCI
// digest, so it can never be mistaken for a mutable tag.
type OCIInvalidDigestError struct {
	Err   error
	Value string
}

func (err OCIInvalidDigestError) Error() string {
	return fmt.Sprintf("invalid digest %q: %s", err.Value, err.Err)
}

func (err OCIInvalidDigestError) Unwrap() error {
	return err.Err
}

// OCIInvalidTagError reports a tag pin that does not satisfy the OCI tag
// grammar.
type OCIInvalidTagError struct {
	Err   error
	Value string
}

func (err OCIInvalidTagError) Error() string {
	return fmt.Sprintf("invalid tag %q: %s", err.Value, err.Err)
}

func (err OCIInvalidTagError) Unwrap() error {
	return err.Err
}

// OCIManifestMediaTypeError reports a manifest descriptor or document whose
// media type is not the OCI image manifest type.
type OCIManifestMediaTypeError struct {
	MediaType string
}

func (err OCIManifestMediaTypeError) Error() string {
	return fmt.Sprintf(
		"unexpected manifest media type %q, expected %q",
		err.MediaType,
		ociv1.MediaTypeImageManifest,
	)
}

// OCIManifestSizeError reports a manifest descriptor whose declared size is
// not positive or exceeds [ociMaxManifestSize].
type OCIManifestSizeError struct {
	Size int64
}

func (err OCIManifestSizeError) Error() string {
	return fmt.Sprintf(
		"manifest size %d is outside the accepted range (0, %d]",
		err.Size,
		ociMaxManifestSize,
	)
}

// OCIArtifactTypeError reports a manifest whose artifact type is not the
// OpenTofu module-package type.
type OCIArtifactTypeError struct {
	ArtifactType string
}

func (err OCIArtifactTypeError) Error() string {
	return fmt.Sprintf(
		"unexpected artifact type %q, expected %q",
		err.ArtifactType,
		ArtifactTypeModulePkg,
	)
}

// OCILayerCountError reports a manifest that does not contain exactly one
// module-zip layer.
type OCILayerCountError struct {
	Count int
}

func (err OCILayerCountError) Error() string {
	return fmt.Sprintf("expected exactly one %q layer, found %d", MediaTypeModuleZip, err.Count)
}

// OCILayerSizeError reports a layer descriptor whose declared size is outside (0, ociMaxLayerSize].
type OCILayerSizeError struct {
	Size int64
}

func (err OCILayerSizeError) Error() string {
	return fmt.Sprintf(
		"layer size %d is outside the accepted range (0, %d]",
		err.Size,
		ociMaxLayerSize,
	)
}

// OCIRestoreError reports a failed destination swap whose previous contents could not be put back.
type OCIRestoreError struct {
	PromoteErr error
	RestoreErr error
	BackupPath string
}

func (err OCIRestoreError) Error() string {
	return fmt.Sprintf(
		"moving module into destination failed: %v; restoring the previous contents failed: %v; backup retained at %s",
		err.PromoteErr,
		err.RestoreErr,
		err.BackupPath,
	)
}

// Unwrap exposes both failures to errors.Is and errors.As.
func (err OCIRestoreError) Unwrap() []error {
	return []error{err.PromoteErr, err.RestoreErr}
}

// OCIDigestVerificationError reports blob bytes that do not match the digest
// the manifest layer descriptor promised.
type OCIDigestVerificationError struct {
	Err    error
	Digest string
}

func (err OCIDigestVerificationError) Error() string {
	return fmt.Sprintf("verifying blob digest %s: %s", err.Digest, err.Err)
}

func (err OCIDigestVerificationError) Unwrap() error {
	return err.Err
}

// OCIRepositoryStore is the narrow seam between the OCI getter and the
// registry client that serves it. Resolve turns a tag or digest reference
// into a manifest descriptor; Fetch streams the blob a descriptor points at,
// taking the descriptor by pointer to keep call frames small. oras-go's
// by-value client is bridged by [OCIRemoteStore], and unit tests can drive
// the getter with a fake store and no network.
type OCIRepositoryStore interface {
	Resolve(ctx context.Context, ref string) (ociv1.Descriptor, error)
	Fetch(ctx context.Context, desc *ociv1.Descriptor) (io.ReadCloser, error)
}

// OCINewStoreFunc builds the [OCIRepositoryStore] serving one repository on
// one registry.
type OCINewStoreFunc func(ctx context.Context, registryDomain, repositoryName string) (OCIRepositoryStore, error)

// OCIGetter is the go-getter v2 implementation of the oci:// protocol.
//
// Source URLs take the form:
//
//	oci://REGISTRY_DOMAIN/REPOSITORY[//SUBDIR][?tag=TAG|?digest=DIGEST]
//
// where REGISTRY_DOMAIN is an OCI Distribution registry host (host:port
// allowed) and REPOSITORY is the full, possibly multi-segment repository
// name. When neither tag nor digest is set, the tag defaults to latest. The
// manifest contract the getter enforces is centralized in
// [ArtifactTypeModulePkg] and [MediaTypeModuleZip].
//
// NewStore is the dependency-injection seam: credential resolution and
// registry transport live entirely behind it, so credential tiers are
// additive changes and tests inject a fake [OCIRepositoryStore]. The seam
// rides as a struct field rather than a function parameter only because the
// go-getter Getter interface fixes the method set, leaving no parameter to
// thread it through.
type OCIGetter struct {
	NewStore OCINewStoreFunc
	Logger   log.Logger
	FS       vfs.FS
	// MaxDecompressedSize bounds archive expansion in bytes; zero uses the default.
	MaxDecompressedSize int64
	// MaxDecompressedFiles bounds archive entry count; zero uses the default.
	MaxDecompressedFiles int
}

var _ getter.Getter = (*OCIGetter)(nil)

// NewOCIGetter returns the oci:// getter wired to the default credential store.
func NewOCIGetter(l log.Logger, v *venv.Venv) *OCIGetter {
	return &OCIGetter{
		NewStore: NewOCIRepositoryStore(l, v),
		Logger:   l,
		FS:       v.FS,
	}
}

// Mode reports directory mode for all oci sources, since oci always
// downloads a module directory.
func (g *OCIGetter) Mode(_ context.Context, _ *url.URL) (getter.Mode, error) {
	return getter.ModeDir, nil
}

// Detect recognizes oci:// sources and oci-forced sources.
func (g *OCIGetter) Detect(req *getter.Request) (bool, error) {
	if req.Forced == SchemeOCI {
		return true, nil
	}

	u, err := url.Parse(req.Src)
	if err == nil && u.Scheme == SchemeOCI {
		return true, nil
	}

	return false, nil
}

// Get downloads the module package at req.Src into req.Dst. It resolves the
// tag or digest to a manifest, requires the [ArtifactTypeModulePkg] artifact
// type, streams the single [MediaTypeModuleZip] layer with digest
// verification, and extracts it, honoring a //SUBDIR selector.
func (g *OCIGetter) Get(ctx context.Context, req *getter.Request) error {
	if g.NewStore == nil || g.Logger == nil || g.FS == nil {
		return ErrOCIGetterNotConfigured
	}

	coords, err := parseOCISource(req.URL())
	if err != nil {
		return err
	}

	store, err := g.NewStore(ctx, coords.registryDomain, coords.repositoryName)
	if err != nil {
		return fmt.Errorf(
			"creating OCI repository store for %s/%s: %w",
			coords.registryDomain, coords.repositoryName, err,
		)
	}

	layer, err := resolveModuleZipLayer(ctx, store, coords.ref)
	if err != nil {
		return err
	}

	if layer.Size <= 0 || layer.Size > ociMaxLayerSize {
		return OCILayerSizeError{Size: layer.Size}
	}

	if layer.Size > ociLayerSizeWarnThreshold {
		g.Logger.Warnf(
			"OCI layer %s declares %d bytes, above the %d byte threshold; downloading it may be slow",
			layer.Digest,
			layer.Size,
			ociLayerSizeWarnThreshold,
		)
	}

	// Download under a temp parent so a partial download never lands in req.Dst.
	parent, err := vfs.MkdirTemp(g.FS, "", "getter")
	if err != nil {
		return err
	}

	defer func() {
		if err := g.FS.RemoveAll(parent); err != nil {
			g.Logger.Warnf("Error removing temporary directory %s: %v", parent, err)
		}
	}()

	zipPath, err := g.fetchModuleZip(ctx, store, &layer, parent)
	if err != nil {
		return err
	}

	return g.extractModule(zipPath, coords.subDir, req.Dst, req.Src, req.Umask)
}

// GetFile always fails, per [ErrOCIGetFileUnsupported].
func (g *OCIGetter) GetFile(_ context.Context, _ *getter.Request) error {
	return ErrOCIGetFileUnsupported
}

func validateOCIQueryParams(queryValues url.Values) error {
	for param, values := range queryValues {
		if param != ociTagQueryKey && param != ociDigestQueryKey {
			return OCIUnsupportedQueryParamError{Param: param}
		}

		if len(values) > 1 {
			return OCIDuplicateQueryParamError{Param: param}
		}

		if len(values) == 0 || values[0] == "" {
			return OCIEmptyQueryParamError{Param: param}
		}
	}

	return nil
}

// ociSourceCoordinates carries the registry coordinates, subdir selector, and
// validated reference parsed from an oci source URL.
type ociSourceCoordinates struct {
	registryDomain string
	repositoryName string
	subDir         string
	ref            string
}

// parseOCISource splits an oci source URL into registry coordinates, the subdir selector, and the validated reference.
func parseOCISource(srcURL *url.URL) (ociSourceCoordinates, error) {
	coords := ociSourceCoordinates{registryDomain: srcURL.Host}
	if coords.registryDomain == "" {
		return coords, ErrOCIMissingRegistryDomain
	}

	coords.repositoryName, coords.subDir = SourceDirSubdir(strings.TrimPrefix(srcURL.Path, "/"))
	if coords.repositoryName == "" {
		return coords, ErrOCIMissingRepositoryName
	}

	// Validated by field, as tofu does, so a :tag suffix cannot ride through ORAS reference splitting into latest.
	fields := registry.Reference{Registry: coords.registryDomain, Repository: coords.repositoryName}
	if err := fields.Validate(); err != nil {
		return coords, ociRepositoryNameError(coords, srcURL.Query(), err)
	}

	ref, err := ociRefFromQuery(srcURL.Query())
	if err != nil {
		return coords, err
	}

	coords.ref = ref

	return coords, nil
}

// ociRepositoryNameError renders a repository validation failure, upgrading a
// docker-style suffix to [OCIEmbeddedReferenceError] with the fully formed
// query-pinned source, so the fix can be pasted verbatim.
func ociRepositoryNameError(coords ociSourceCoordinates, queryValues url.Values, err error) error {
	name, queryKey, ref, found := cutOCIEmbeddedReference(coords.repositoryName)
	if !found {
		// A single %w keeps the whole message intact: the CLI renderer splits multi-wrapped errors into bare bullets.
		return fmt.Errorf("%w %q: %s", ErrOCIInvalidRepositoryName, coords.repositoryName, err.Error())
	}

	queryKey, ref = suggestedOCIQueryPin(queryValues, queryKey, ref)

	return OCIEmbeddedReferenceError{
		RepositoryName:  coords.repositoryName,
		SuggestedSource: ociQuerySource(coords.registryDomain, name, coords.subDir, queryKey, ref),
	}
}

// cutOCIEmbeddedReference splits a docker-style "@digest" or ":tag" suffix off
// a repository path, recognizing a suffix only when it satisfies the digest or
// tag grammar and leaves a valid name, so the rewrite always parses on paste.
func cutOCIEmbeddedReference(repositoryName string) (string, string, string, bool) {
	if name, ref, found := strings.Cut(repositoryName, "@"); found && name != "" {
		if _, err := digest.Parse(ref); err != nil {
			return repositoryName, "", "", false
		}

		// The digest wins over a ":tag" also present, matching Docker's NAME:TAG@DIGEST form.
		name, _, _ = cutOCIEmbeddedTag(name)
		if err := (registry.Reference{Repository: name}).ValidateRepository(); err != nil {
			return repositoryName, "", "", false
		}

		return name, ociDigestQueryKey, ref, true
	}

	if name, ref, found := cutOCIEmbeddedTag(repositoryName); found {
		if err := (registry.Reference{Repository: name}).ValidateRepository(); err != nil {
			return repositoryName, "", "", false
		}

		return name, ociTagQueryKey, ref, true
	}

	return repositoryName, "", "", false
}

// cutOCIEmbeddedTag splits a ":tag" suffix off the last path segment when the suffix satisfies the tag grammar.
func cutOCIEmbeddedTag(repositoryName string) (string, string, bool) {
	idx := strings.LastIndex(repositoryName, ":")
	if idx <= 0 || idx < strings.LastIndex(repositoryName, "/") {
		return repositoryName, "", false
	}

	ref := repositoryName[idx+1:]
	if err := (registry.Reference{Reference: ref}).ValidateReferenceAsTag(); err != nil {
		return repositoryName, "", false
	}

	return repositoryName[:idx], ref, true
}

// suggestedOCIQueryPin keeps an explicit query pin over the embedded suffix in the rewrite.
func suggestedOCIQueryPin(queryValues url.Values, queryKey, ref string) (string, string) {
	if value := queryValues.Get(ociDigestQueryKey); value != "" {
		return ociDigestQueryKey, value
	}

	if value := queryValues.Get(ociTagQueryKey); value != "" {
		return ociTagQueryKey, value
	}

	return queryKey, ref
}

// ociQuerySource rebuilds the source string with the reference moved into a query argument.
func ociQuerySource(registryDomain, repositoryName, subDir, queryKey, ref string) string {
	src := SchemeOCI + "://" + registryDomain + "/" + repositoryName
	if subDir != "" {
		src += "//" + subDir
	}

	return src + "?" + queryKey + "=" + ref
}

// ociRefFromQuery validates the source query and returns the reference to
// resolve: the digest when pinned, the tag otherwise, defaulting to
// [ociDefaultTag] when neither key is present. Each key must appear at most
// once with a non-empty value that satisfies its grammar, so a typo can
// never silently resolve a different reference.
func ociRefFromQuery(queryValues url.Values) (string, error) {
	if err := validateOCIQueryParams(queryValues); err != nil {
		return "", err
	}

	_, hasTag := queryValues[ociTagQueryKey]
	_, hasDigest := queryValues[ociDigestQueryKey]

	if hasTag && hasDigest {
		return "", ErrOCITagDigestExclusive
	}

	if hasDigest {
		value := queryValues.Get(ociDigestQueryKey)
		if _, err := digest.Parse(value); err != nil {
			return "", OCIInvalidDigestError{Value: value, Err: err}
		}

		return value, nil
	}

	if hasTag {
		value := queryValues.Get(ociTagQueryKey)
		if err := (registry.Reference{Reference: value}).ValidateReferenceAsTag(); err != nil {
			return "", OCIInvalidTagError{Value: value, Err: err}
		}

		return value, nil
	}

	return ociDefaultTag, nil
}

// resolveModuleZipLayer resolves ref to a manifest, enforces the module
// package contract, and returns the single module-zip layer descriptor.
func resolveModuleZipLayer(
	ctx context.Context,
	store OCIRepositoryStore,
	ref string,
) (ociv1.Descriptor, error) {
	manifestDesc, err := store.Resolve(ctx, ref)
	if err != nil {
		return ociv1.Descriptor{}, OCIReferenceResolutionError{Ref: ref, Err: err}
	}

	if manifestDesc.MediaType != ociv1.MediaTypeImageManifest {
		return ociv1.Descriptor{}, OCIManifestMediaTypeError{MediaType: manifestDesc.MediaType}
	}

	if manifestDesc.Size <= 0 || manifestDesc.Size > ociMaxManifestSize {
		return ociv1.Descriptor{}, OCIManifestSizeError{Size: manifestDesc.Size}
	}

	manifestReader, err := store.Fetch(ctx, &manifestDesc)
	if err != nil {
		return ociv1.Descriptor{}, fmt.Errorf(
			"fetching OCI manifest %s: %w",
			manifestDesc.Digest,
			err,
		)
	}

	manifestBytes, readErr := content.ReadAll(manifestReader, manifestDesc)
	if err := errors.Join(readErr, manifestReader.Close()); err != nil {
		return ociv1.Descriptor{}, fmt.Errorf(
			"fetching OCI manifest %s: %w",
			manifestDesc.Digest,
			err,
		)
	}

	var manifest ociv1.Manifest
	if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
		return ociv1.Descriptor{}, fmt.Errorf(
			"parsing OCI manifest %s: %w",
			manifestDesc.Digest,
			err,
		)
	}

	if manifest.MediaType != "" && manifest.MediaType != ociv1.MediaTypeImageManifest {
		return ociv1.Descriptor{}, OCIManifestMediaTypeError{MediaType: manifest.MediaType}
	}

	if manifest.ArtifactType != ArtifactTypeModulePkg {
		return ociv1.Descriptor{}, OCIArtifactTypeError{ArtifactType: manifest.ArtifactType}
	}

	var zipLayers []ociv1.Descriptor

	for _, layer := range manifest.Layers {
		if layer.MediaType == MediaTypeModuleZip {
			zipLayers = append(zipLayers, layer)
		}
	}

	if len(zipLayers) != 1 {
		return ociv1.Descriptor{}, OCILayerCountError{Count: len(zipLayers)}
	}

	return zipLayers[0], nil
}

// fetchModuleZip streams the layer blob into a zip file under parent,
// verifying the bytes against the layer descriptor digest, and returns the
// file path.
func (g *OCIGetter) fetchModuleZip(
	ctx context.Context,
	store OCIRepositoryStore,
	layer *ociv1.Descriptor,
	parent string,
) (string, error) {
	blob, err := store.Fetch(ctx, layer)
	if err != nil {
		return "", fmt.Errorf("fetching OCI layer %s: %w", layer.Digest, err)
	}

	defer func() {
		if err := blob.Close(); err != nil {
			g.Logger.Warnf("Error closing OCI layer blob %s: %v", layer.Digest, err)
		}
	}()

	zipFile, err := vfs.CreateTemp(g.FS, parent, "module-*.zip")
	if err != nil {
		return "", err
	}

	verifyReader := content.NewVerifyReader(blob, *layer)

	if _, err := io.Copy(zipFile, verifyReader); err != nil {
		if closeErr := zipFile.Close(); closeErr != nil {
			g.Logger.Warnf("Error closing temporary file %s: %v", zipFile.Name(), closeErr)
		}

		return "", fmt.Errorf("streaming OCI layer %s: %w", layer.Digest, err)
	}

	if err := zipFile.Close(); err != nil {
		return "", err
	}

	if err := verifyReader.Verify(); err != nil {
		return "", OCIDigestVerificationError{Digest: layer.Digest.String(), Err: err}
	}

	return zipFile.Name(), nil
}

// extractModule expands the module zip under the shipped extraction bounds.
func (g *OCIGetter) extractModule(
	zipPath, subDir, dstPath, source string,
	umask os.FileMode,
) error {
	sizeLimit := ociMaxDecompressedSize
	if g.MaxDecompressedSize > 0 {
		sizeLimit = g.MaxDecompressedSize
	}

	filesLimit := ociMaxDecompressedFiles
	if g.MaxDecompressedFiles > 0 {
		filesLimit = g.MaxDecompressedFiles
	}

	return g.extractModuleWithLimits(zipPath, subDir, dstPath, source, umask, sizeLimit, filesLimit)
}

// extractModuleWithLimits expands the module zip into a staging directory beside
// dstPath, bounded by sizeLimit and filesLimit, then promotes the requested
// subDir into dstPath. source only labels errors.
func (g *OCIGetter) extractModuleWithLimits(
	zipPath, subDir, dstPath, source string,
	umask os.FileMode,
	sizeLimit int64,
	filesLimit int,
) error {
	const ownerWriteGlobalReadExecutePerms = 0755

	dstParent := filepath.Dir(dstPath)
	if err := g.FS.MkdirAll(dstParent, ownerWriteGlobalReadExecutePerms); err != nil {
		return fmt.Errorf("creating destination path %s: %w", dstParent, err)
	}

	// Stage beside dstPath so promoting the tree is a same-filesystem rename.
	staging, err := vfs.MkdirTemp(g.FS, dstParent, ".terragrunt-oci")
	if err != nil {
		return err
	}

	var keepStaging bool

	defer func() {
		if keepStaging {
			g.Logger.Warnf(
				"Keeping staging directory %s: it holds the previous module contents",
				staging,
			)

			return
		}

		if err := g.FS.RemoveAll(staging); err != nil {
			g.Logger.Warnf("Error removing staging directory %s: %v", staging, err)
		}
	}()

	unzipPath := filepath.Join(staging, "unzip")
	if err := vfs.NewZipDecompressor(
		vfs.WithFileSizeLimit(sizeLimit),
		vfs.WithFilesLimit(filesLimit),
	).Unzip(g.Logger, g.FS, unzipPath, zipPath, umask); err != nil {
		return fmt.Errorf("extracting OCI module archive: %w", err)
	}

	// Clients resolve //subdir themselves, so subDir stays literal and the Stat below guards it.
	sourcePath := filepath.Join(unzipPath, subDir)

	if _, err := g.FS.Stat(sourcePath); err != nil {
		return ModuleDownloadErr{
			sourceURL: source,
			details:   fmt.Sprintf("could not stat download path %s: %s", sourcePath, err),
		}
	}

	err = g.promoteModule(staging, sourcePath, dstPath)

	if _, ok := errors.AsType[OCIRestoreError](err); ok {
		keepStaging = true
	}

	return err
}

// promoteModule swaps sourcePath into dstPath through renames, restoring the
// previous contents when the swap fails. The displaced tree parks in staging,
// which the caller removes.
func (g *OCIGetter) promoteModule(staging, sourcePath, dstPath string) error {
	backupPath := filepath.Join(staging, "previous")

	_, statErr := g.FS.Stat(dstPath)
	replacing := statErr == nil

	if replacing {
		if err := g.FS.Rename(dstPath, backupPath); err != nil {
			return fmt.Errorf("clearing destination path %s: %w", dstPath, err)
		}
	}

	if err := g.FS.Rename(sourcePath, dstPath); err != nil {
		promoteErr := fmt.Errorf("moving module into destination path %s: %w", dstPath, err)
		if !replacing {
			return promoteErr
		}

		if restoreErr := g.FS.Rename(backupPath, dstPath); restoreErr != nil {
			return OCIRestoreError{
				PromoteErr: promoteErr,
				RestoreErr: restoreErr,
				BackupPath: backupPath,
			}
		}

		return promoteErr
	}

	return nil
}
