// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1

package planfile

import (
	"archive/zip"
	"encoding/json"
	"fmt"
	"io"
	"maps"
	"path"
	"slices"
	"strings"
	"time"

	version "github.com/hashicorp/go-version"
	"github.com/hashicorp/terraform/internal/configs/configload"
)

const configSnapshotPrefix = "tfconfig/"
const configSnapshotManifestFile = configSnapshotPrefix + "modules.json"
const configSnapshotModulePrefix = configSnapshotPrefix + "m-"

type configSnapshotModuleRecord struct {
	Key        string `json:"Key"`
	SourceAddr string `json:"Source,omitempty"`
	VersionStr string `json:"Version,omitempty"`
	Dir        string `json:"Dir"`
}
type configSnapshotModuleManifest []configSnapshotModuleRecord

func ReadConfigSnapshot(z *zip.Reader) (*configload.Snapshot, error) {
	// Errors from this function are expected to be reported with some
	// additional prefix context about them being in a config snapshot,
	// so they should not themselves refer to the config snapshot.
	// They are also generally indicative of an invalid file, and so since
	// plan files should not be hand-constructed we don't need to worry
	// about making the messages user-actionable.

	snap := &configload.Snapshot{
		Modules: map[string]*configload.SnapshotModule{},
	}
	var manifestSrc []byte

	// For processing our source files, we'll just sweep over all the files
	// and react to the one-by-one to start, and then clean up afterwards
	// when we'll presumably have found the manifest file.
	for _, file := range z.File {
		switch {

		case file.Name == configSnapshotManifestFile:
			// It's the manifest file, so we'll just read it raw into
			// manifestSrc for now and process it below.
			r, err := file.Open()
			if err != nil {
				return nil, fmt.Errorf("failed to open module manifest: %s", r)
			}
			manifestSrc, err = io.ReadAll(r)
			if err != nil {
				return nil, fmt.Errorf("failed to read module manifest: %s", r)
			}

		case strings.HasPrefix(file.Name, configSnapshotModulePrefix):
			relName := file.Name[len(configSnapshotModulePrefix):]
			moduleKey, fileName := path.Split(relName)

			// moduleKey should currently have a trailing slash on it, which we
			// can use to recognize the difference between the root module
			// (just a trailing slash) and no module path at all (empty string).
			if moduleKey == "" {
				// ignore invalid config entry
				continue
			}
			moduleKey = moduleKey[:len(moduleKey)-1] // trim trailing slash

			r, err := file.Open()
			if err != nil {
				return nil, fmt.Errorf("failed to open snapshot of %s from module %q: %s", fileName, moduleKey, err)
			}
			fileSrc, err := io.ReadAll(r)
			if err != nil {
				return nil, fmt.Errorf("failed to read snapshot of %s from module %q: %s", fileName, moduleKey, err)
			}

			if _, exists := snap.Modules[moduleKey]; !exists {
				snap.Modules[moduleKey] = &configload.SnapshotModule{
					Files: map[string][]byte{},
					// Will fill in everything else afterwards, when we
					// process the manifest.
				}
			}
			snap.Modules[moduleKey].Files[fileName] = fileSrc
		}
	}

	if manifestSrc == nil {
		return nil, fmt.Errorf("config snapshot does not have manifest file")
	}

	var manifest configSnapshotModuleManifest
	err := json.Unmarshal(manifestSrc, &manifest)
	if err != nil {
		return nil, fmt.Errorf("invalid module manifest: %s", err)
	}

	for _, record := range manifest {
		modSnap, exists := snap.Modules[record.Key]
		if !exists {
			// We'll allow this, assuming that it's a module with no files.
			// This is still weird, since we generally reject modules with
			// no files, but we'll allow it because downstream errors will
			// catch it in that case.
			modSnap = &configload.SnapshotModule{
				Files: map[string][]byte{},
			}
			snap.Modules[record.Key] = modSnap
		}
		modSnap.SourceAddr = record.SourceAddr
		modSnap.Dir = record.Dir
		if record.VersionStr != "" {
			v, err := version.NewVersion(record.VersionStr)
			if err != nil {
				return nil, fmt.Errorf("manifest has invalid version string %q for module %q", record.VersionStr, record.Key)
			}
			modSnap.Version = v
		}
	}

	// Finally, we'll make sure we don't have any errant files for modules that
	// aren't in the manifest.
	for k := range snap.Modules {
		found := false
		for _, record := range manifest {
			if record.Key == k {
				found = true
				break
			}
		}
		if !found {
			return nil, fmt.Errorf("found files for module %q that isn't recorded in the manifest", k)
		}
	}

	return snap, nil
}

// WriteConfigSnapshot adds to the given zip.Writer one or more files
// representing the given snapshot.
//
// This file creates new files in the writer, so any already-open writer
// for the file will be invalidated by this call. The writer remains open
// when this function returns.
func WriteConfigSnapshot(snap *configload.Snapshot, z *zip.Writer) error {
	// Errors from this function are expected to be reported with some
	// additional prefix context about them being in a config snapshot,
	// so they should not themselves refer to the config snapshot.
	// They are also indicative of a bug in the caller, so they do not
	// need to be user-actionable.

	var manifest configSnapshotModuleManifest

	// We'll re-use this fileheader for each Create we do below.

	for _, k := range slices.Sorted(maps.Keys(snap.Modules)) {
		snapMod := snap.Modules[k]
		record := configSnapshotModuleRecord{
			Dir:        snapMod.Dir,
			Key:        k,
			SourceAddr: snapMod.SourceAddr,
		}
		if snapMod.Version != nil {
			record.VersionStr = snapMod.Version.String()
		}
		manifest = append(manifest, record)

		pathPrefix := fmt.Sprintf("%s%s/", configSnapshotModulePrefix, k)
		for filename, src := range snapMod.Files {
			zh := &zip.FileHeader{
				Name:     pathPrefix + filename,
				Method:   zip.Deflate,
				Modified: time.Now(),
			}
			w, err := z.CreateHeader(zh)
			if err != nil {
				return fmt.Errorf("failed to create snapshot of %s from module %q: %s", zh.Name, k, err)
			}
			_, err = w.Write(src)
			if err != nil {
				return fmt.Errorf("failed to write snapshot of %s from module %q: %s", zh.Name, k, err)
			}
		}
	}

	// Now we'll write our manifest
	{
		zh := &zip.FileHeader{
			Name:     configSnapshotManifestFile,
			Method:   zip.Deflate,
			Modified: time.Now(),
		}
		src, err := json.MarshalIndent(manifest, "", "  ")
		if err != nil {
			return fmt.Errorf("failed to serialize module manifest: %s", err)
		}
		w, err := z.CreateHeader(zh)
		if err != nil {
			return fmt.Errorf("failed to create module manifest: %s", err)
		}
		_, err = w.Write(src)
		if err != nil {
			return fmt.Errorf("failed to write module manifest: %s", err)
		}
	}

	return nil
}
