package model

import (
	"errors"
	"fmt"
	"net/url"
	"path/filepath"
	"regexp"
	"strings"
)

var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)

func ValidateConfig(c Config) error {
	if c.SchemaVersion != SchemaVersion {
		return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion)
	}
	repos := make(map[string]Repository, len(c.Repositories))
	for _, repo := range c.Repositories {
		if err := ValidateRepository(repo); err != nil {
			return fmt.Errorf("repository %q: %w", repo.ID, err)
		}
		if _, exists := repos[repo.ID]; exists {
			return fmt.Errorf("duplicate repository id %q", repo.ID)
		}
		repos[repo.ID] = repo
	}
	jobs := make(map[string]struct{}, len(c.Jobs))
	for _, job := range c.Jobs {
		if err := ValidateJob(job); err != nil {
			return fmt.Errorf("job %q: %w", job.ID, err)
		}
		if _, exists := jobs[job.ID]; exists {
			return fmt.Errorf("duplicate job id %q", job.ID)
		}
		jobs[job.ID] = struct{}{}
		if job.Type != JobRsync {
			if _, exists := repos[job.RepositoryID]; !exists {
				return fmt.Errorf("job %q references unknown repository %q", job.ID, job.RepositoryID)
			}
		}
	}
	notifications := make(map[string]struct{}, len(c.Notifications))
	for _, target := range c.Notifications {
		if err := ValidateNotification(target); err != nil {
			return fmt.Errorf("notification %q: %w", target.ID, err)
		}
		if _, exists := notifications[target.ID]; exists {
			return fmt.Errorf("duplicate notification id %q", target.ID)
		}
		notifications[target.ID] = struct{}{}
	}
	if !filepath.IsAbs(c.Settings.RestoreRoot) {
		return errors.New("restoreRoot must be absolute")
	}
	return nil
}

func ValidateNotification(target NotificationTarget) error {
	if target.SchemaVersion != SchemaVersion || !idPattern.MatchString(target.ID) {
		return errors.New("invalid schemaVersion or id")
	}
	if strings.TrimSpace(target.Name) == "" {
		return errors.New("name is required")
	}
	switch target.Type {
	case "unraid":
	case "gotify":
		parsed, err := url.Parse(target.URL)
		if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
			return errors.New("Gotify URL must be an absolute HTTP or HTTPS URL")
		}
		if !idPattern.MatchString(target.TokenRef) {
			return errors.New("Gotify tokenRef must be a valid secret ID")
		}
	default:
		return fmt.Errorf("unsupported notification type %q", target.Type)
	}
	allowed := map[string]bool{"success": true, "warning": true, "failed": true}
	if target.Enabled && len(target.Events) == 0 {
		return errors.New("enabled notification requires at least one event")
	}
	for _, event := range target.Events {
		if !allowed[event] {
			return fmt.Errorf("unsupported event %q", event)
		}
	}
	return nil
}

func ValidateJob(j Job) error {
	if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) {
		return errors.New("invalid schemaVersion or id")
	}
	if strings.TrimSpace(j.Name) == "" {
		return errors.New("name is required")
	}
	switch j.Type {
	case JobShare, JobAppdata, JobDocker, JobVM, JobFlash, JobRsync:
	default:
		return fmt.Errorf("unsupported job type %q", j.Type)
	}
	if len(j.Sources) == 0 && j.Type != JobFlash {
		return errors.New("at least one source is required")
	}
	if j.Type == JobFlash && len(j.Sources) == 0 {
		j.Sources = []Source{{Path: "/boot"}}
	}
	for _, source := range j.Sources {
		if source.Path != "" && !filepath.IsAbs(source.Path) {
			return fmt.Errorf("source path %q must be absolute", source.Path)
		}
		if source.Path == "" && source.WorkloadID == "" {
			return errors.New("source requires path or workloadId")
		}
	}
	if j.Type == JobDocker || j.Type == JobVM {
		for _, source := range j.Sources {
			if source.WorkloadID == "" {
				return errors.New("docker and vm jobs require workload selections")
			}
		}
	}
	for _, exclude := range j.Excludes {
		if strings.ContainsAny(exclude, "\x00\r\n") {
			return errors.New("exclude contains forbidden control characters")
		}
	}
	if j.Type == JobRsync {
		return validateRsyncJob(j)
	}
	if strings.TrimSpace(j.RepositoryID) == "" {
		return errors.New("repositoryId is required")
	}
	if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" {
		return errors.New("compression must be auto, off, or max")
	}
	if j.CPUCores < 0 || j.CPUCores > 256 {
		return errors.New("cpuCores must be between 0 and 256")
	}
	if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" {
		return errors.New("consistency mode must be live or stop")
	}
	if j.Retention.KeepWithinDays < 0 || j.Retention.Daily < 0 || j.Retention.Weekly < 0 || j.Retention.Monthly < 0 {
		return errors.New("retention values cannot be negative")
	}
	if j.Retention.KeepWithinDays == 0 && j.Retention.Daily == 0 && j.Retention.Weekly == 0 && j.Retention.Monthly == 0 {
		return errors.New("retention must keep at least one age or calendar policy")
	}
	return nil
}

func validateRsyncJob(j Job) error {
	target := filepath.Clean(strings.TrimSpace(j.Rsync.Target))
	if !filepath.IsAbs(target) {
		return errors.New("rsync target must be absolute")
	}
	if !pathWithin(target, "/mnt/user") && !pathWithin(target, "/mnt/disks") && !pathWithin(target, "/mnt/remotes") {
		return errors.New("rsync target must be below /mnt/user, /mnt/disks, or /mnt/remotes")
	}
	for _, source := range j.Sources {
		if source.WorkloadID != "" || source.Path == "" {
			return errors.New("rsync jobs require filesystem paths")
		}
		cleanSource := filepath.Clean(source.Path)
		if pathWithin(target, cleanSource) || pathWithin(cleanSource, target) {
			return fmt.Errorf("rsync source %q and target %q must not contain each other", cleanSource, target)
		}
	}
	return nil
}

func pathWithin(path, root string) bool {
	rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

func ValidateRepository(r Repository) error {
	if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) {
		return errors.New("invalid schemaVersion or id")
	}
	if r.Name == "" || r.Location == "" || r.PasswordRef == "" {
		return errors.New("name, location, and passwordRef are required")
	}
	if !idPattern.MatchString(r.PasswordRef) || (r.CredentialRef != "" && !idPattern.MatchString(r.CredentialRef)) {
		return errors.New("secret references must be valid IDs")
	}
	switch r.Type {
	case RepositoryLocal, RepositorySMB, RepositoryNFS, RepositorySFTP:
	default:
		return fmt.Errorf("unsupported repository type %q", r.Type)
	}
	if r.Type == RepositoryLocal && !filepath.IsAbs(r.Location) {
		return errors.New("local repository location must be absolute")
	}
	if (r.Type == RepositorySMB || r.Type == RepositoryNFS) && r.Mount != nil && r.Mount.Managed {
		if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) {
			return errors.New("managed mount requires remote and absolute mountPoint")
		}
	}
	if r.Type == RepositorySFTP && r.CredentialRef != "" {
		knownHosts := r.Options["knownHostsPath"]
		if !filepath.IsAbs(knownHosts) || strings.ContainsAny(knownHosts, " \t\r\n") {
			return errors.New("SFTP key authentication requires an absolute knownHostsPath without whitespace")
		}
	}
	return nil
}
