package updater

import (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"net/http"
	"os"
	"path/filepath"
	"strings"

	"github.com/safing/jess/filesig"
	"github.com/safing/jess/lhash"
	"github.com/safing/portbase/log"
	"github.com/safing/portbase/utils"
)

// ScanStorage scans root within the storage dir and adds found
// resources to the registry. If an error occurred, it is logged
// and the last error is returned. Everything that was found
// despite errors is added to the registry anyway. Leave root
// empty to scan the full storage dir.
func (reg *ResourceRegistry) ScanStorage(root string) error {
	var lastError error

	// prep root
	if root == "" {
		root = reg.storageDir.Path
	} else {
		var err error
		root, err = filepath.Abs(root)
		if err != nil {
			return err
		}
		if !strings.HasPrefix(root, reg.storageDir.Path) {
			return errors.New("supplied scan root path not within storage")
		}
	}

	// walk fs
	_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
		// skip tmp dir (including errors trying to read it)
		if strings.HasPrefix(path, reg.tmpDir.Path) {
			return filepath.SkipDir
		}

		// handle walker error
		if err != nil {
			lastError = fmt.Errorf("%s: could not read %s: %w", reg.Name, path, err)
			log.Warning(lastError.Error())
			return nil
		}

		// Ignore file signatures.
		if strings.HasSuffix(path, filesig.Extension) {
			return nil
		}

		// get relative path to storage
		relativePath, err := filepath.Rel(reg.storageDir.Path, path)
		if err != nil {
			lastError = fmt.Errorf("%s: could not get relative path of %s: %w", reg.Name, path, err)
			log.Warning(lastError.Error())
			return nil
		}

		// convert to identifier and version
		relativePath = filepath.ToSlash(relativePath)
		identifier, version, ok := GetIdentifierAndVersion(relativePath)
		if !ok {
			// file does not conform to format
			return nil
		}

		// fully ignore directories that also have an identifier - these will be unpacked resources
		if info.IsDir() {
			return filepath.SkipDir
		}

		// save
		err = reg.AddResource(identifier, version, nil, true, false, false)
		if err != nil {
			lastError = fmt.Errorf("%s: could not get add resource %s v%s: %w", reg.Name, identifier, version, err)
			log.Warning(lastError.Error())
		}
		return nil
	})

	return lastError
}

// LoadIndexes loads the current release indexes from disk
// or will fetch a new version if not available and the
// registry is marked as online.
func (reg *ResourceRegistry) LoadIndexes(ctx context.Context) error {
	var firstErr error
	client := &http.Client{}
	for _, idx := range reg.getIndexes() {
		err := reg.loadIndexFile(idx)
		if err == nil {
			log.Debugf("%s: loaded index %s", reg.Name, idx.Path)
		} else if reg.Online {
			// try to download the index file if a local disk version
			// does not exist or we don't have permission to read it.
			if errors.Is(err, fs.ErrNotExist) || errors.Is(err, fs.ErrPermission) {
				err = reg.downloadIndex(ctx, client, idx)
			}
		}

		if err != nil && firstErr == nil {
			firstErr = err
		}
	}

	return firstErr
}

// getIndexes returns a copy of the index.
// The indexes itself are references.
func (reg *ResourceRegistry) getIndexes() []*Index {
	reg.RLock()
	defer reg.RUnlock()

	indexes := make([]*Index, len(reg.indexes))
	copy(indexes, reg.indexes)
	return indexes
}

func (reg *ResourceRegistry) loadIndexFile(idx *Index) error {
	indexPath := filepath.Join(reg.storageDir.Path, filepath.FromSlash(idx.Path))
	indexData, err := os.ReadFile(indexPath)
	if err != nil {
		return fmt.Errorf("failed to read index file %s: %w", idx.Path, err)
	}

	// Verify signature, if enabled.
	if verifOpts := reg.GetVerificationOptions(idx.Path); verifOpts != nil {
		// Load and check signature.
		verifiedHash, _, err := reg.loadAndVerifySigFile(verifOpts, indexPath+filesig.Extension)
		if err != nil {
			switch verifOpts.DiskLoadPolicy {
			case SignaturePolicyRequire:
				return fmt.Errorf("failed to verify signature of index %s: %w", idx.Path, err)
			case SignaturePolicyWarn:
				log.Warningf("%s: failed to verify signature of index %s: %s", reg.Name, idx.Path, err)
			case SignaturePolicyDisable:
				log.Debugf("%s: failed to verify signature of index %s: %s", reg.Name, idx.Path, err)
			}
		}

		// Check if signature checksum matches the index data.
		if err == nil && !verifiedHash.Matches(indexData) {
			switch verifOpts.DiskLoadPolicy {
			case SignaturePolicyRequire:
				return fmt.Errorf("index file %s does not match signature", idx.Path)
			case SignaturePolicyWarn:
				log.Warningf("%s: index file %s does not match signature", reg.Name, idx.Path)
			case SignaturePolicyDisable:
				log.Debugf("%s: index file %s does not match signature", reg.Name, idx.Path)
			}
		}
	}

	// Parse the index file.
	indexFile, err := ParseIndexFile(indexData, idx.Channel, idx.LastRelease)
	if err != nil {
		return fmt.Errorf("failed to parse index file %s: %w", idx.Path, err)
	}

	// Update last seen release.
	idx.LastRelease = indexFile.Published

	// Warn if there aren't any releases in the index.
	if len(indexFile.Releases) == 0 {
		log.Debugf("%s: index %s has no releases", reg.Name, idx.Path)
		return nil
	}

	// Add index releases to available resources.
	err = reg.AddResources(indexFile.Releases, idx, false, true, idx.PreRelease)
	if err != nil {
		log.Warningf("%s: failed to add resource: %s", reg.Name, err)
	}
	return nil
}

func (reg *ResourceRegistry) loadAndVerifySigFile(verifOpts *VerificationOptions, sigFilePath string) (*lhash.LabeledHash, []byte, error) {
	// Load signature file.
	sigFileData, err := os.ReadFile(sigFilePath)
	if err != nil {
		return nil, nil, fmt.Errorf("failed to read signature file: %w", err)
	}

	// Extract all signatures.
	sigs, err := filesig.ParseSigFile(sigFileData)
	switch {
	case len(sigs) == 0 && err != nil:
		return nil, nil, fmt.Errorf("failed to parse signature file: %w", err)
	case len(sigs) == 0:
		return nil, nil, errors.New("no signatures found in signature file")
	case err != nil:
		return nil, nil, fmt.Errorf("failed to parse signature file: %w", err)
	}

	// Verify all signatures.
	var verifiedHash *lhash.LabeledHash
	for _, sig := range sigs {
		fd, err := filesig.VerifyFileData(
			sig,
			nil,
			verifOpts.TrustStore,
		)
		if err != nil {
			return nil, sigFileData, err
		}

		// Save or check verified hash.
		if verifiedHash == nil {
			verifiedHash = fd.FileHash()
		} else if !fd.FileHash().Equal(verifiedHash) {
			// Return an error if two valid hashes mismatch.
			// For simplicity, all hash algorithms must be the same for now.
			return nil, sigFileData, errors.New("file hashes from different signatures do not match")
		}
	}

	return verifiedHash, sigFileData, nil
}

// CreateSymlinks creates a directory structure with unversioned symlinks to the given updates list.
func (reg *ResourceRegistry) CreateSymlinks(symlinkRoot *utils.DirStructure) error {
	err := os.RemoveAll(symlinkRoot.Path)
	if err != nil {
		return fmt.Errorf("failed to wipe symlink root: %w", err)
	}

	err = symlinkRoot.Ensure()
	if err != nil {
		return fmt.Errorf("failed to create symlink root: %w", err)
	}

	reg.RLock()
	defer reg.RUnlock()

	for _, res := range reg.resources {
		if res.SelectedVersion == nil {
			return fmt.Errorf("no selected version available for %s", res.Identifier)
		}

		targetPath := res.SelectedVersion.storagePath()
		linkPath := filepath.Join(symlinkRoot.Path, filepath.FromSlash(res.Identifier))
		linkPathDir := filepath.Dir(linkPath)

		err = symlinkRoot.EnsureAbsPath(linkPathDir)
		if err != nil {
			return fmt.Errorf("failed to create dir for link: %w", err)
		}

		relativeTargetPath, err := filepath.Rel(linkPathDir, targetPath)
		if err != nil {
			return fmt.Errorf("failed to get relative target path: %w", err)
		}

		err = os.Symlink(relativeTargetPath, linkPath)
		if err != nil {
			return fmt.Errorf("failed to link %s: %w", res.Identifier, err)
		}
	}

	return nil
}
