package snapshot

import (
	"archive/zip"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path"
	"slices"
	"strings"

	"github.com/liamg/memoryfs"

	iox "github.com/aquasecurity/trivy/pkg/x/io"
)

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

	tfplanFilename = "tfplan"
)

type (
	configSnapshotModuleRecord struct {
		Key        string `json:"Key"`
		SourceAddr string `json:"Source,omitempty"`
		Dir        string `json:"Dir"`
	}

	configSnapshotModuleManifest []configSnapshotModuleRecord
)

var errNoTerraformPlan = errors.New("no terraform plan file")

func IsPlanSnapshot(r io.Reader) bool {
	zr, err := readSnapshot(r)
	if err != nil {
		return false
	}
	return containsTfplanFile(zr)
}

func readSnapshot(r io.Reader) (*zip.Reader, error) {
	if r == nil {
		return nil, errors.New("reader is nil")
	}

	rsa, size, err := iox.NewReadSeekerAtWithSize(r)
	if err != nil {
		return nil, err
	}

	zr, err := zip.NewReader(rsa, size)
	if err != nil {
		return nil, err
	}

	return zr, nil
}

func parseSnapshot(r io.Reader) (*snapshot, error) {
	zr, err := readSnapshot(r)
	if err != nil {
		return nil, err
	}

	if !containsTfplanFile(zr) {
		return nil, errNoTerraformPlan
	}

	snap := &snapshot{
		modules: make(map[string]*snapshotModule),
	}

	var moduleManifest configSnapshotModuleManifest

	for _, file := range zr.File {
		switch {
		case file.Name == configSnapshotManifestFile:
			var err error
			moduleManifest, err = readModuleManifest(file)
			if err != nil {
				return nil, err
			}
		case strings.HasPrefix(file.Name, configSnapshotModulePrefix):
			if err := snap.addFile(file); err != nil {
				return nil, err
			}
		}
	}

	for _, record := range moduleManifest {
		// skip non-local modules
		if record.Dir != "." && !strings.HasPrefix(record.SourceAddr, ".") {
			delete(snap.modules, record.Key)
			continue
		}
		modSnap := snap.getOrCreateModuleSnapshot(record.Key)
		modSnap.dir = record.Dir
	}

	return snap, nil
}

func containsTfplanFile(zr *zip.Reader) bool {
	return slices.ContainsFunc(zr.File, func(f *zip.File) bool {
		return f.Name == tfplanFilename
	})
}

func readModuleManifest(f *zip.File) (configSnapshotModuleManifest, error) {
	r, err := f.Open()
	if err != nil {
		return nil, fmt.Errorf("failed to open module manifest: %s", r)
	}
	defer r.Close()

	var manifest configSnapshotModuleManifest
	if err := json.NewDecoder(r).Decode(&manifest); err != nil {
		return nil, fmt.Errorf("failed to read module manifest: %s", f.Name)
	}
	return manifest, nil
}

type (
	snapshotModule struct {
		// dir is the path, relative to the root directory given when the
		// snapshot was created, where the module appears in the snapshot's
		// virtual filesystem.
		dir string

		// files is a map from each configuration file filename for the
		// module to a raw byte representation of the source file contents.
		files map[string][]byte
	}

	snapshot struct {
		modules map[string]*snapshotModule
	}
)

func (s *snapshot) addFile(file *zip.File) error {
	relName := file.Name[len(configSnapshotModulePrefix):]
	moduleKey, fileName := path.Split(relName)
	if moduleKey == "" {
		return nil
	}
	moduleKey = moduleKey[:len(moduleKey)-1]

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

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

	modSnap := s.getOrCreateModuleSnapshot(moduleKey)
	modSnap.files[fileName] = fileSrc
	return nil
}

func (s *snapshot) getOrCreateModuleSnapshot(key string) *snapshotModule {
	modSnap, exists := s.modules[key]
	if !exists {
		modSnap = &snapshotModule{
			files: make(map[string][]byte),
		}
		s.modules[key] = modSnap
	}
	return modSnap
}

func (s *snapshot) toFS() (fs.FS, error) {
	fsys := memoryfs.New()

	for _, module := range s.modules {
		if err := fsys.MkdirAll(module.dir, fs.ModePerm); err != nil && !errors.Is(err, os.ErrExist) {
			return nil, err
		}
		for filename, file := range module.files {
			filePath := filename
			if module.dir != "" {
				filePath = path.Join(module.dir, filename)
			}
			if err := fsys.WriteFile(filePath, file, fs.ModePerm); err != nil {
				return nil, fmt.Errorf("failed to add file: %w", err)
			}
		}
	}
	return fsys, nil
}
