package config

import (
	"bytes"
	"crypto/md5"
	"fmt"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"sync"

	"github.com/projectdiscovery/gologger"
	filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
	"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
	"github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
	"github.com/projectdiscovery/utils/env"
	"github.com/projectdiscovery/utils/errkit"
	fileutil "github.com/projectdiscovery/utils/file"
	folderutil "github.com/projectdiscovery/utils/folder"
)

// DefaultConfig is the default nuclei configuration
// all config values and default are centralized here
var DefaultConfig *Config

type Config struct {
	TemplatesDirectory string `json:"nuclei-templates-directory,omitempty"`

	// customtemplates exists in templates directory with the name of custom-templates provider
	// below custom paths are absolute paths to respective custom-templates directories
	CustomS3TemplatesDirectory     string `json:"custom-s3-templates-directory"`
	CustomGitHubTemplatesDirectory string `json:"custom-github-templates-directory"`
	CustomGitLabTemplatesDirectory string `json:"custom-gitlab-templates-directory"`
	CustomAzureTemplatesDirectory  string `json:"custom-azure-templates-directory"`

	TemplateVersion        string `json:"nuclei-templates-version,omitempty"`
	NucleiIgnoreHash       string `json:"nuclei-ignore-hash,omitempty"`
	LogAllEvents           bool   `json:"-"` // when enabled logs all events (more than verbose)
	HideTemplateSigWarning bool   `json:"-"` // when enabled disables template signature warning

	// LatestXXX are not meant to be used directly and is used as
	// local cache of nuclei version check endpoint
	// these fields are only update during nuclei version check
	// TODO: move these fields to a separate unexported struct as they are not meant to be used directly
	LatestNucleiVersion          string           `json:"nuclei-latest-version"`
	LatestNucleiTemplatesVersion string           `json:"nuclei-templates-latest-version"`
	LatestNucleiIgnoreHash       string           `json:"nuclei-latest-ignore-hash,omitempty"`
	Logger                       *gologger.Logger `json:"-"` // logger

	// internal / unexported fields
	disableUpdates bool     `json:"-"` // disable updates both version check and template updates
	homeDir        string   `json:"-"` //  User Home Directory
	configDir      string   `json:"-"` //  Nuclei Global Config Directory
	debugArgs      []string `json:"-"` // debug args

	m sync.Mutex
}

// IsCustomTemplate determines whether a given template is custom-built or part of the official Nuclei templates.
// It checks if the template's path matches any of the predefined custom template directories
// (such as S3, GitHub, GitLab, and Azure directories). If the template resides in any of these directories,
// it is considered custom. Additionally, if the template's path does not start with the main Nuclei TemplatesDirectory,
// it is also considered custom. This function assumes that template paths are either absolute
// or relative to the same base as the paths configured in DefaultConfig.
func (c *Config) IsCustomTemplate(templatePath string) bool {
	customDirs := []string{
		c.CustomS3TemplatesDirectory,
		c.CustomGitHubTemplatesDirectory,
		c.CustomGitLabTemplatesDirectory,
		c.CustomAzureTemplatesDirectory,
	}

	for _, dir := range customDirs {
		if dir != "" && filepathutil.IsPathWithinDirectory(templatePath, dir) {
			return true
		}
	}
	if c.TemplatesDirectory == "" {
		return false
	}
	return !filepathutil.IsPathWithinDirectory(templatePath, c.TemplatesDirectory)
}

// WriteVersionCheckData writes version check data to config file
func (c *Config) WriteVersionCheckData(ignorehash, nucleiVersion, templatesVersion string) error {
	updated := false
	if ignorehash != "" && c.LatestNucleiIgnoreHash != ignorehash {
		c.LatestNucleiIgnoreHash = ignorehash
		updated = true
	}
	if nucleiVersion != "" && c.LatestNucleiVersion != nucleiVersion {
		c.LatestNucleiVersion = nucleiVersion
		updated = true
	}
	if templatesVersion != "" && c.LatestNucleiTemplatesVersion != templatesVersion {
		c.LatestNucleiTemplatesVersion = templatesVersion
		updated = true
	}
	// write config to disk if any of the fields are updated
	if updated {
		return c.WriteTemplatesConfig()
	}
	return nil
}

// GetTemplateDir returns the nuclei templates directory absolute path
func (c *Config) GetTemplateDir() string {
	val, _ := filepath.Abs(c.TemplatesDirectory)
	return val
}

// DisableUpdateCheck disables update check and template updates
func (c *Config) DisableUpdateCheck() {
	c.m.Lock()
	defer c.m.Unlock()
	c.disableUpdates = true
}

// CanCheckForUpdates returns true if update check is enabled
func (c *Config) CanCheckForUpdates() bool {
	c.m.Lock()
	defer c.m.Unlock()
	return !c.disableUpdates
}

// NeedsTemplateUpdate returns true if template installation/update is required
func (c *Config) NeedsTemplateUpdate() bool {
	c.m.Lock()
	defer c.m.Unlock()
	return !c.disableUpdates && (c.TemplateVersion == "" || IsOutdatedVersion(c.TemplateVersion, c.LatestNucleiTemplatesVersion) || !fileutil.FolderExists(c.TemplatesDirectory))
}

// NeedsIgnoreFileUpdate returns true if Ignore file hash is different (aka ignore file is outdated)
func (c *Config) NeedsIgnoreFileUpdate() bool {
	c.m.Lock()
	defer c.m.Unlock()
	return c.NucleiIgnoreHash == "" || c.NucleiIgnoreHash != c.LatestNucleiIgnoreHash
}

// UpdateNucleiIgnoreHash updates the nuclei ignore hash in config
func (c *Config) UpdateNucleiIgnoreHash() error {
	// calculate hash of ignore file and update config
	ignoreFilePath := c.GetIgnoreFilePath()
	if fileutil.FileExists(ignoreFilePath) {
		bin, err := os.ReadFile(ignoreFilePath)
		if err != nil {
			return errkit.Newf("could not read nuclei ignore file: %v", err)
		}
		c.NucleiIgnoreHash = fmt.Sprintf("%x", md5.Sum(bin))
		// write config to disk
		return c.WriteTemplatesConfig()
	}
	return errkit.New("ignore file not found: could not update nuclei ignore hash")
}

// GetConfigDir returns the nuclei configuration directory
func (c *Config) GetConfigDir() string {
	return c.configDir
}

// GetKeysDir returns the nuclei signer keys directory
func (c *Config) GetKeysDir() string {
	return filepath.Join(c.configDir, "keys")
}

// GetAllCustomTemplateDirs returns all custom template directories
func (c *Config) GetAllCustomTemplateDirs() []string {
	return []string{c.CustomS3TemplatesDirectory, c.CustomGitHubTemplatesDirectory, c.CustomGitLabTemplatesDirectory, c.CustomAzureTemplatesDirectory}
}

// GetReportingConfigFilePath returns the nuclei reporting config file path
func (c *Config) GetReportingConfigFilePath() string {
	return filepath.Join(c.configDir, ReportingConfigFilename)
}

// GetIgnoreFilePath returns the nuclei ignore file path
func (c *Config) GetIgnoreFilePath() string {
	return filepath.Join(c.configDir, NucleiIgnoreFileName)
}

func (c *Config) GetTemplateIndexFilePath() string {
	return filepath.Join(c.TemplatesDirectory, NucleiTemplatesIndexFileName)
}

// GetChecksumFilePath returns checksum file path of nuclei templates
func (c *Config) GetChecksumFilePath() string {
	return filepath.Join(c.TemplatesDirectory, NucleiTemplatesCheckSumFileName)
}

// GetFlagsConfigFilePath returns the nuclei cli config file path
func (c *Config) GetFlagsConfigFilePath() string {
	return filepath.Join(c.configDir, CLIConfigFileName)
}

// GetNewAdditions returns new template additions in current template release
// if .new-additions file is not present empty slice is returned
func (c *Config) GetNewAdditions() []string {
	arr := []string{}
	newAdditionsPath := filepath.Join(c.TemplatesDirectory, NewTemplateAdditionsFileName)
	if !fileutil.FileExists(newAdditionsPath) {
		return arr
	}
	bin, err := os.ReadFile(newAdditionsPath)
	if err != nil {
		return arr
	}
	for v := range strings.FieldsSeq(string(bin)) {
		if IsTemplateWithRoot(v, c.TemplatesDirectory) {
			arr = append(arr, v)
		}
	}
	return arr
}

// GetCacheDir returns the nuclei cache directory
// with new version of nuclei cache directory is changed
// instead of saving resume files in nuclei config directory
// they are saved in nuclei cache directory
func (c *Config) GetCacheDir() string {
	return folderutil.AppCacheDirOrDefault(".nuclei-cache", BinaryName)
}

// SetConfigDir sets the nuclei configuration directory
// and appropriate changes are made to the config
func (c *Config) SetConfigDir(dir string) {
	c.configDir = dir
	if err := c.createConfigDirIfNotExists(); err != nil {
		c.Logger.Fatal().Msgf("Could not create nuclei config directory at %s: %s", c.configDir, err)
	}

	// if folder already exists read config or create new
	if err := c.ReadTemplatesConfig(); err != nil {
		// create new config
		applyDefaultConfig()
		if err2 := c.WriteTemplatesConfig(); err2 != nil {
			c.Logger.Fatal().Msgf("Could not create nuclei config file at %s: %s", c.getTemplatesConfigFilePath(), err2)
		}
	}

	// while other config files are optional, ignore file is mandatory
	// since it is used to ignore templates with weak matchers
	c.copyIgnoreFile()
}

// SetTemplatesDir sets the new nuclei templates directory
func (c *Config) SetTemplatesDir(dirPath string) {
	if dirPath != "" && !filepath.IsAbs(dirPath) {
		cwd, _ := os.Getwd()
		dirPath = filepath.Join(cwd, dirPath)
	}
	c.TemplatesDirectory = dirPath
	// Update the custom templates directory
	c.CustomGitHubTemplatesDirectory = filepath.Join(dirPath, CustomGitHubTemplatesDirName)
	c.CustomS3TemplatesDirectory = filepath.Join(dirPath, CustomS3TemplatesDirName)
	c.CustomGitLabTemplatesDirectory = filepath.Join(dirPath, CustomGitLabTemplatesDirName)
	c.CustomAzureTemplatesDirectory = filepath.Join(dirPath, CustomAzureTemplatesDirName)
}

// SetTemplatesVersion sets the new nuclei templates version
func (c *Config) SetTemplatesVersion(version string) error {
	c.TemplateVersion = version
	// write config to disk
	if err := c.WriteTemplatesConfig(); err != nil {
		return errkit.Newf("could not write nuclei config file at %s: %v", c.getTemplatesConfigFilePath(), err)
	}
	return nil
}

// ReadTemplatesConfig reads the nuclei templates config file
func (c *Config) ReadTemplatesConfig() error {
	if !fileutil.FileExists(c.getTemplatesConfigFilePath()) {
		return errkit.Newf("nuclei config file at %s does not exist", c.getTemplatesConfigFilePath())
	}
	var cfg *Config
	bin, err := os.ReadFile(c.getTemplatesConfigFilePath())
	if err != nil {
		return errkit.Newf("could not read nuclei config file at %s: %v", c.getTemplatesConfigFilePath(), err)
	}
	if err := json.Unmarshal(bin, &cfg); err != nil {
		return errkit.Newf("could not unmarshal nuclei config file at %s: %v", c.getTemplatesConfigFilePath(), err)
	}
	// apply config
	c.TemplatesDirectory = cfg.TemplatesDirectory
	c.TemplateVersion = cfg.TemplateVersion
	c.NucleiIgnoreHash = cfg.NucleiIgnoreHash
	c.LatestNucleiIgnoreHash = cfg.LatestNucleiIgnoreHash
	c.LatestNucleiTemplatesVersion = cfg.LatestNucleiTemplatesVersion
	return nil
}

// WriteTemplatesConfig writes the nuclei templates config file
func (c *Config) WriteTemplatesConfig() error {
	// check if config folder exists if not create one
	if err := c.createConfigDirIfNotExists(); err != nil {
		return err
	}
	bin, err := json.Marshal(c)
	if err != nil {
		return errkit.Newf("failed to marshal nuclei config: %v", err)
	}
	if err = os.WriteFile(c.getTemplatesConfigFilePath(), bin, 0600); err != nil {
		return errkit.Newf("failed to write nuclei config file at %s: %v", c.getTemplatesConfigFilePath(), err)
	}
	return nil
}

// WriteTemplatesIndex writes the nuclei templates index file
func (c *Config) WriteTemplatesIndex(index map[string]string) error {
	indexFile := c.GetTemplateIndexFilePath()
	var buff bytes.Buffer
	for k, v := range index {
		_, _ = buff.WriteString(k + "," + v + "\n")
	}
	return os.WriteFile(indexFile, buff.Bytes(), 0600)
}

// getTemplatesConfigFilePath returns configDir/.templates-config.json file path
func (c *Config) getTemplatesConfigFilePath() string {
	return filepath.Join(c.configDir, TemplateConfigFileName)
}

// createConfigDirIfNotExists creates the nuclei config directory if not exists
func (c *Config) createConfigDirIfNotExists() error {
	if !fileutil.FolderExists(c.configDir) {
		if err := fileutil.CreateFolder(c.configDir); err != nil {
			return errkit.Newf("could not create nuclei config directory at %s: %v", c.configDir, err)
		}
	}
	return nil
}

// copyIgnoreFile copies the nuclei ignore file default config directory
// to the current config directory
func (c *Config) copyIgnoreFile() {
	if err := c.createConfigDirIfNotExists(); err != nil {
		c.Logger.Error().Msgf("Could not create nuclei config directory at %s: %s", c.configDir, err)
		return
	}
	ignoreFilePath := c.GetIgnoreFilePath()
	if !fileutil.FileExists(ignoreFilePath) {
		// copy ignore file from default config directory
		if err := fileutil.CopyFile(filepath.Join(folderutil.AppConfigDirOrDefault(FallbackConfigFolderName, BinaryName), NucleiIgnoreFileName), ignoreFilePath); err != nil {
			c.Logger.Error().Msgf("Could not copy nuclei ignore file at %s: %s", ignoreFilePath, err)
		}
	}
}

// IsDebugArgEnabled checks if debug arg is enabled
// this could be a feature specific to debugging like PPROF or printing stats
// of max host error etc
func (c *Config) IsDebugArgEnabled(arg string) bool {
	return slices.Contains(c.debugArgs, arg)
}

// parseDebugArgs from string
func (c *Config) parseDebugArgs(data string) {
	// use space as separator instead of commas
	tmp := strings.Fields(data)
	for _, v := range tmp {
		key := v
		value := ""
		// if it is key value pair then split it
		if strings.Contains(v, "=") {
			parts := strings.SplitN(v, "=", 2)
			if len(parts) != 2 {
				continue
			}
			key, value = strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
		}
		if value == "false" || value == "0" {
			// if false or disabled then skip
			continue
		}
		switch key {
		case DebugArgHostErrorStats:
			c.debugArgs = append(c.debugArgs, DebugArgHostErrorStats)
		case DebugExportURLPattern:
			c.debugArgs = append(c.debugArgs, DebugExportURLPattern)
		}
	}
}

func init() {
	ConfigDir := folderutil.AppConfigDirOrDefault(FallbackConfigFolderName, BinaryName)

	if cfgDir := os.Getenv(NucleiConfigDirEnv); cfgDir != "" {
		ConfigDir = cfgDir
	}

	// create config directory if not exists
	if !fileutil.FolderExists(ConfigDir) {
		if err := fileutil.CreateFolder(ConfigDir); err != nil {
			gologger.Error().Msgf("failed to create config directory at %v got: %s", ConfigDir, err)
		}
	}
	DefaultConfig = &Config{
		homeDir:   folderutil.HomeDirOrDefault(""),
		configDir: ConfigDir,
		Logger:    gologger.DefaultLogger,
	}

	// when enabled will log events in more verbosity than -v or -debug
	// ex: N templates are excluded
	// with this switch enabled nuclei will print details of above N templates
	if value := env.GetEnvOrDefault("NUCLEI_LOG_ALL", false); value {
		DefaultConfig.LogAllEvents = true
	}
	if value := env.GetEnvOrDefault("HIDE_TEMPLATE_SIG_WARNING", false); value {
		DefaultConfig.HideTemplateSigWarning = true
	}

	// try to read config from file
	if err := DefaultConfig.ReadTemplatesConfig(); err != nil {
		gologger.Verbose().Msgf("config file not found, creating new config file at %s", DefaultConfig.getTemplatesConfigFilePath())
		applyDefaultConfig()
		// write config to file
		if err := DefaultConfig.WriteTemplatesConfig(); err != nil {
			gologger.Error().Msgf("failed to write config file at %s got: %s", DefaultConfig.getTemplatesConfigFilePath(), err)
		}
	}

	// Loads/updates paths of custom templates
	// Note: custom templates paths should not be updated in config file
	// and even if it is changed we don't follow it since it is not expected behavior
	// If custom templates are in default locations only then they are loaded while running nuclei
	DefaultConfig.SetTemplatesDir(DefaultConfig.TemplatesDirectory)
	DefaultConfig.parseDebugArgs(env.GetEnvOrDefault("NUCLEI_ARGS", ""))

	// expose the templates directory to the yaml preprocessor so include
	// directives can be confined to it without yaml importing this package
	// (which would create an import cycle).
	yaml.TemplateBaseDirProvider = func() string {
		return DefaultConfig.GetTemplateDir()
	}
}

// Add Default Config adds default when .templates-config.json file is not present
func applyDefaultConfig() {
	DefaultConfig.TemplatesDirectory = filepath.Join(DefaultConfig.homeDir, NucleiTemplatesDirName)
	// updates all necessary paths
	DefaultConfig.SetTemplatesDir(DefaultConfig.TemplatesDirectory)
}
