// Copyright 2024 The Update Framework Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License
//
// SPDX-License-Identifier: Apache-2.0
//

package multirepo

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"slices"

	"github.com/theupdateframework/go-tuf/v2/metadata"
	"github.com/theupdateframework/go-tuf/v2/metadata/config"
	"github.com/theupdateframework/go-tuf/v2/metadata/updater"
)

// ErrInvalidRepoName is returned when a repository name contains path traversal
// components or is otherwise invalid for use as a directory name.
var ErrInvalidRepoName = errors.New("invalid repository name")

// validRepoNamePattern defines the allowed characters for repository names.
// Names must start with an alphanumeric character and may contain alphanumeric
// characters, dots, hyphens, and underscores. This prevents path traversal
// attacks while allowing typical repository naming conventions.
// Examples: "sigstore-tuf-root", "staging", "repo.v2", "my_repo_1"
var validRepoNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)

// The following represent the map file described in TAP 4
type Mapping struct {
	Paths        []string `json:"paths"`
	Repositories []string `json:"repositories"`
	Threshold    int      `json:"threshold"`
	Terminating  bool     `json:"terminating"`
}

type MultiRepoMapType struct {
	Repositories map[string][]string `json:"repositories"`
	Mapping      []*Mapping          `json:"mapping"`
}

// MultiRepoConfig represents the configuration for a set of trusted TUF clients
type MultiRepoConfig struct {
	RepoMap           *MultiRepoMapType
	TrustedRoots      map[string][]byte
	LocalMetadataDir  string
	LocalTargetsDir   string
	DisableLocalCache bool
}

// MultiRepoClient represents a multi-repository TUF client.
//
// Thread Safety: MultiRepoClient is NOT safe for concurrent use. If multiple
// goroutines need to use a MultiRepoClient concurrently, external synchronization
// is required (e.g., a sync.Mutex). Alternatively, create separate MultiRepoClient
// instances for each goroutine.
type MultiRepoClient struct {
	TUFClients map[string]*updater.Updater
	Config     *MultiRepoConfig
}

type targetMatch struct {
	targetInfo   *metadata.TargetFiles
	repositories []string
}

// NewConfig returns configuration for a multi-repo TUF client
func NewConfig(repoMap []byte, roots map[string][]byte) (*MultiRepoConfig, error) {
	// error if we don't have the necessary arguments
	if len(repoMap) == 0 || len(roots) == 0 {
		return nil, fmt.Errorf("failed to create multi-repository config: no map file and/or trusted root metadata is provided")
	}

	// unmarshal the map file (note: should we expect/support unrecognized values here?)
	var mapFile *MultiRepoMapType
	if err := json.Unmarshal(repoMap, &mapFile); err != nil {
		return nil, err
	}

	// make sure we have enough trusted root metadata files provided based on the repository list
	for repo := range mapFile.Repositories {
		// check if we have a trusted root metadata for this repository
		_, ok := roots[repo]
		if !ok {
			return nil, fmt.Errorf("no trusted root metadata provided for repository - %s", repo)
		}
	}

	return &MultiRepoConfig{
		RepoMap:      mapFile,
		TrustedRoots: roots,
	}, nil
}

// New returns a multi-repository TUF client. All repositories described in the provided map file are initialized too
func New(config *MultiRepoConfig) (*MultiRepoClient, error) {
	// create a multi repo client instance
	client := &MultiRepoClient{
		Config:     config,
		TUFClients: map[string]*updater.Updater{},
	}

	// validate repository names before using them as filesystem paths
	for repoName := range config.RepoMap.Repositories {
		if err := validateRepoName(repoName); err != nil {
			return nil, fmt.Errorf("repository %q: %w", repoName, err)
		}
	}

	// create TUF clients for each repository listed in the map file
	if err := client.initTUFClients(); err != nil {
		return nil, err
	}
	return client, nil
}

// initTUFClients loop through all repositories listed in the map file and create a TUF client for each
func (client *MultiRepoClient) initTUFClients() error {
	log := metadata.GetLogger()

	// loop through each repository listed in the map file and initialize it
	for repoName, repoURL := range client.Config.RepoMap.Repositories {
		log.Info("Initializing", "name", repoName, "url", repoURL[0])

		// get the trusted root file from the location specified in the map file relevant to its path
		// NOTE: the root.json file is expected to be in a folder named after the repository it corresponds to placed in the same folder as the map file
		// i.e <client.cfg.BootstrapDir>/<repo-name>/root.json
		rootBytes, ok := client.Config.TrustedRoots[repoName]
		if !ok {
			return fmt.Errorf("failed to get trusted root metadata from config for repository - %s", repoName)
		}

		// path of where each of the repository's metadata files will be persisted
		metadataDir := filepath.Join(client.Config.LocalMetadataDir, repoName)

		// location of where the target files will be downloaded (propagated to each client from the multi-repo config)
		// WARNING: Do note that using a single folder for storing targets from various repositories as it might lead to a conflict
		targetsDir := client.Config.LocalTargetsDir
		if len(client.Config.LocalTargetsDir) == 0 {
			// if it was not set, create a targets folder under each repository so there's no chance of conflict
			targetsDir = filepath.Join(metadataDir, "targets")
		}

		// ensure paths exist, doesn't do anything if caching is disabled
		err := client.Config.EnsurePathsExist()
		if err != nil {
			return err
		}

		// default config for a TUF Client
		cfg, err := config.New(repoURL[0], rootBytes) // support only one mirror for the time being
		if err != nil {
			return err
		}
		cfg.LocalMetadataDir = metadataDir
		cfg.LocalTargetsDir = targetsDir
		cfg.DisableLocalCache = client.Config.DisableLocalCache // propagate global cache policy

		// create a new Updater instance for each repository
		repoTUFClient, err := updater.New(cfg)
		if err != nil {
			return fmt.Errorf("failed to create Updater instance: %w", err)
		}

		// save the client
		client.TUFClients[repoName] = repoTUFClient
		log.Info("Successfully initialized", "name", repoName, "url", repoURL)
	}
	return nil
}

// Refresh refreshes all repository clients
func (client *MultiRepoClient) Refresh() error {
	log := metadata.GetLogger()

	// loop through each initialized TUF client and refresh it
	for name, repoTUFClient := range client.TUFClients {
		log.Info("Refreshing", "name", name)
		err := repoTUFClient.Refresh()
		if err != nil {
			return err
		}
	}
	return nil
}

// GetTopLevelTargets returns the top-level target files for all repositories
func (client *MultiRepoClient) GetTopLevelTargets() (map[string]*metadata.TargetFiles, error) {
	// collection of all target files for all clients
	result := map[string]*metadata.TargetFiles{}

	// loop through each repository
	for _, tufClient := range client.TUFClients {
		// loop through the top level targets for each repository
		for targetName := range tufClient.GetTopLevelTargets() {
			// see if this target should be kept, this goes through the TAP4 search algorithm
			targetInfo, _, err := client.GetTargetInfo(targetName)
			if err != nil {
				// we skip saving this target since there's no way/policy do download it with this map.json file
				// possible causes like not enough repositories for that threshold, target info mismatch, etc.
				return nil, err
			}
			// check if this target file is already present in the collection
			if val, ok := result[targetName]; ok {
				// target file is already present
				if !val.Equal(*targetInfo) {
					// target files have the same target name but have different target infos
					// this means the map.json file allows downloading two different target infos mapped to the same target name
					// TODO: confirm if this should raise an error
					return nil, fmt.Errorf("target name conflict")
				}
				// same target info, no need to do anything
			} else {
				// save the target
				result[targetName] = targetInfo
			}
		}
	}
	return result, nil
}

// GetTargetInfo returns metadata.TargetFiles instance with information
// for targetPath and a list of repositories that serve the matching target.
// It implements the TAP 4 search algorithm.
func (client *MultiRepoClient) GetTargetInfo(targetPath string) (*metadata.TargetFiles, []string, error) {
	terminated := false
	// loop through each mapping
	for _, eachMap := range client.Config.RepoMap.Mapping {
		// loop through each path for this mapping
		for _, pathPattern := range eachMap.Paths {
			// check if the targetPath matches each path mapping
			patternMatched, err := filepath.Match(pathPattern, targetPath)
			if err != nil {
				// error looking for a match
				return nil, nil, err
			} else {
				if patternMatched {
					// if there's a pattern match, loop through all of the repositories listed for that mapping
					// and see if we can find a consensus among them to cover the threshold for that mapping
					var matchedTargetGroups []targetMatch
					for _, repoName := range eachMap.Repositories {
						// get target info from that repository
						newTargetInfo, err := client.TUFClients[repoName].GetTargetInfo(targetPath)
						if err != nil {
							// failed to get target info for the given target
							// there's probably no such target
							// skip the rest and proceed trying to get target info from the next repository
							continue
						}
						found := false
						// loop through all target infos we found so far
						for i, target := range matchedTargetGroups {
							// see if we already have found one like that
							if target.targetInfo.Equal(*newTargetInfo) {
								found = true
								// if so, update its repository list
								if slices.Contains(target.repositories, repoName) {
									// we have a duplicate repository listed in the mapping
									// decide if we should error out here
									// nevertheless we won't take it into account when we calculate the threshold
								} else {
									// a new repository vouched for this target
									matchedTargetGroups[i].repositories = append(target.repositories, repoName)
								}
							}
						}
						// this target as not part of the list so far, so we should add it
						if !found {
							matchedTargetGroups = append(matchedTargetGroups, targetMatch{
								targetInfo:   newTargetInfo,
								repositories: []string{repoName},
							})
						}
						// proceed with searching for this target in the next repository
					}
					// we went through all repositories listed in that mapping
					// lets see if we have matched the threshold consensus for the given target file
					var result *targetMatch
					for _, target := range matchedTargetGroups {
						// compare thresholds for each target info we found with the value stated for its mapping
						if len(target.repositories) >= eachMap.Threshold {
							// this target has enough repositories signed for it
							if result != nil {
								// it seems there's more than one target info matching the threshold for this mapping
								// it is a conflict since it's impossible to establish a consensus which of the found targets
								// we should actually trust, so we error out
								return nil, nil, fmt.Errorf("more than one target info matching the necessary threshold value")
							} else {
								// this is the first target we found matching the necessary threshold so save it
								result = &target
							}
						}
					}
					// search finished, see if we have found a matching target
					if result != nil {
						return result.targetInfo, result.repositories, nil
					}
					// if we are here, we haven't found enough target infos to match the threshold number
					// for this mapping
					if eachMap.Terminating {
						// stop the search if this was a terminating map
						terminated = eachMap.Terminating
						break
					}
				}
			}
			// no match, continue looking at the next path pattern from this mapping
		}
		// stop the search if this was a terminating map, otherwise continue with the next mapping
		if terminated {
			break
		}
	}
	// looped through all mappings and there was nothing, not even a terminating one
	return nil, nil, fmt.Errorf("target info not found")
}

// DownloadTarget downloads the target file specified by targetFile
func (client *MultiRepoClient) DownloadTarget(repos []string, targetFile *metadata.TargetFiles, filePath, targetBaseURL string) (string, []byte, error) {
	log := metadata.GetLogger()

	for _, repoName := range repos {
		repoClient, ok := client.TUFClients[repoName]
		if !ok {
			return "", nil, fmt.Errorf("no client found for repo %q", repoName)
		}

		// see if the target is already present locally
		targetPath, targetBytes, err := repoClient.FindCachedTarget(targetFile, filePath)
		if err != nil {
			return "", nil, err
		}
		if len(targetPath) != 0 && len(targetBytes) != 0 {
			// we already got the target for this target info cached locally, so return it
			log.Info("Target already present locally from repo", "target", targetFile.Path, "repo", repoName)
			return targetPath, targetBytes, nil
		}
		// not present locally, so let's try to download it
		targetPath, targetBytes, err = repoClient.DownloadTarget(targetFile, filePath, targetBaseURL)
		if err != nil {
			// TODO: decide if we should error if one repository serves the expected target info, but we fail to download the actual target
			// try downloading the target from the next available repository
			continue
		}
		// we got the target for this target info, so return it
		log.Info("Downloaded target from repo", "target", targetFile.Path, "repo", repoName)
		return targetPath, targetBytes, nil
	}
	// error out as we haven't succeeded downloading the target file
	return "", nil, fmt.Errorf("failed to download target file %s", targetFile.Path)
}

func (cfg *MultiRepoConfig) EnsurePathsExist() error {
	if cfg.DisableLocalCache {
		return nil
	}
	for _, path := range []string{cfg.LocalMetadataDir, cfg.LocalTargetsDir} {
		// Use 0700 for cache directories: only the owner can read, write, and
		// access the directory. This prevents other users on shared systems from
		// reading or writing to the TUF cache, which could be a security risk.
		// If different permissions are needed, pre-create the directories with
		// the desired permissions before calling this function.
		err := os.MkdirAll(path, 0700)
		if err != nil {
			return err
		}
	}
	return nil
}

// validateRepoName checks that a repository name is safe to use as a directory
// component. Repository names must start with an alphanumeric character and
// contain only alphanumeric characters, dots, hyphens, and underscores.
// This prevents path traversal attacks when the repository name is used in filepath.Join.
func validateRepoName(name string) error {
	if !validRepoNamePattern.MatchString(name) {
		return fmt.Errorf("%w: %q must start with alphanumeric and contain only alphanumeric, '.', '-', or '_' characters", ErrInvalidRepoName, name)
	}
	return nil
}
