package zddc

import (
	"fmt"
	"strings"
)

// AppNames is the canonical set of app HTML files the server can serve
// (from disk, the site .zddc.zip bundle, or the embedded default). Order
// is stable for reproducible rendering.
//
// All seven HTML tools belong here — including browse, form, and tables.
//
// Markdown editing used to be a dedicated tool ("mdedit"); it now
// lives as a plugin inside browse (browse/js/preview-markdown.js).
var AppNames = []string{"archive", "transmittal", "classifier", "landing", "browse", "form", "tables"}

// IsKnownApp reports whether name is one of the canonical apps.
func IsKnownApp(name string) bool {
	for _, n := range AppNames {
		if n == name {
			return true
		}
	}
	return false
}

// ValidatePattern returns an error if pattern is not a syntactically
// well-formed email-glob. The matcher in MatchesPattern is forgiving and
// will silently fail to match malformed patterns (e.g., "alice@@x" or
// patterns with spaces); we want explicit rejection at write time so a
// human typo shows up as a 400 instead of "your rule mysteriously does
// nothing".
//
// Rules:
//   - non-empty after trimming surrounding whitespace
//   - no internal whitespace
//   - at most one '@'
//   - if an '@' is present, both local and domain parts are non-empty
//   - the bare token "@" is rejected (covered by the non-empty parts rule)
func ValidatePattern(pattern string) error {
	if pattern == "" {
		return fmt.Errorf("pattern is empty")
	}
	if strings.TrimSpace(pattern) != pattern {
		return fmt.Errorf("pattern has leading or trailing whitespace")
	}
	if strings.ContainsAny(pattern, " \t\n\r") {
		return fmt.Errorf("pattern contains whitespace")
	}
	at := strings.Count(pattern, "@")
	if at > 1 {
		return fmt.Errorf("pattern contains more than one '@'")
	}
	if at == 1 {
		parts := strings.SplitN(pattern, "@", 2)
		if parts[0] == "" {
			return fmt.Errorf("pattern has empty local part before '@'")
		}
		if parts[1] == "" {
			return fmt.Errorf("pattern has empty domain part after '@'")
		}
	}
	return nil
}

// ValidateFile returns a slice of (field, message) errors for any
// malformed entry in zf. An empty slice means the file is acceptable to
// write. The returned errors include enough field context that the editor
// can render them next to the offending row.
type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

// ValidateProjectName returns an error if name is not acceptable as a new
// directory name created under cfg.Root. The rules mirror the reserved-prefix
// policy enforced elsewhere (resolvePath, ScanZddcFiles, ServeProjectList) so
// a project created here is enumerable by the same listing code.
//
// Rules:
//   - length 1..64
//   - first char alphanumeric (rejects leading '.' and '_', matching the
//     hidden-segment convention)
//   - subsequent chars alphanumeric, '-', or '_'
//   - rejects path separators, whitespace, and any '.' anywhere (so "..",
//     ".hidden", "foo.bar" all fail — directory names stay flat)
func ValidateProjectName(name string) error {
	if name == "" {
		return fmt.Errorf("name is empty")
	}
	if len(name) > 64 {
		return fmt.Errorf("name exceeds 64 characters")
	}
	for i, r := range name {
		switch {
		case r >= 'A' && r <= 'Z':
		case r >= 'a' && r <= 'z':
		case r >= '0' && r <= '9':
		case (r == '-' || r == '_') && i > 0:
			// allowed in the body, not as the leading character
		default:
			return fmt.Errorf("name contains invalid character %q at position %d", r, i)
		}
	}
	return nil
}

func ValidateFile(zf ZddcFile) []FieldError {
	var errs []FieldError
	check := func(field string, vals []string) {
		for i, v := range vals {
			if err := ValidatePattern(v); err != nil {
				errs = append(errs, FieldError{
					Field:   fmt.Sprintf("%s[%d]", field, i),
					Message: err.Error(),
				})
			}
		}
	}
	for principal := range zf.ACL.Permissions {
		if err := ValidatePattern(principal); err != nil {
			errs = append(errs, FieldError{
				Field:   fmt.Sprintf("acl.permissions[%q]", principal),
				Message: err.Error(),
			})
		}
	}
	check("admins", zf.Admins)
	if len(zf.Title) > 200 {
		errs = append(errs, FieldError{
			Field:   "title",
			Message: "title exceeds 200 characters",
		})
	}
	// views: each entry names a known tool and (optionally) a config file
	// resolved under <dir>/.zddc.d/ — so it must be a safe relative filename
	// (no slashes, no traversal, no leading dot).
	for shape, v := range zf.Views {
		if v.Tool == "" || !IsKnownApp(v.Tool) {
			errs = append(errs, FieldError{
				Field:   fmt.Sprintf("views.%s.tool", shape),
				Message: fmt.Sprintf("unknown tool %q (known: %s)", v.Tool, strings.Join(AppNames, ", ")),
			})
		}
		if v.Config != "" {
			if strings.ContainsAny(v.Config, "/\\") || v.Config == "." || v.Config == ".." || strings.HasPrefix(v.Config, ".") {
				errs = append(errs, FieldError{
					Field:   fmt.Sprintf("views.%s.config", shape),
					Message: "config must be a plain filename (resolved under .zddc.d/); no slashes, traversal, or leading dot",
				})
			}
		}
	}
	// worm: is a list of principal patterns (email-globs, @role:name,
	// or bare role names) that get write-once-create inside the WORM
	// zone. Validate each as an email-glob unless it's a role
	// reference (role names are validated by the role machinery).
	for i, principal := range zf.Worm {
		if strings.HasPrefix(principal, "@role:") {
			continue // role refs validated elsewhere
		}
		// A bare name with no "@" could be a role name; ValidatePattern
		// accepts it (no @, no whitespace), and MatchesPrincipal
		// resolves it as a role if one is defined. So this only
		// rejects genuinely malformed entries (whitespace, double @).
		if err := ValidatePattern(principal); err != nil {
			errs = append(errs, FieldError{
				Field:   fmt.Sprintf("worm[%d]", i),
				Message: err.Error(),
			})
		}
	}
	return errs
}
