package zddc

import (
	"fmt"
	"strings"
)

// AppNames is the canonical set of app HTML files the server resolves
// via the apps fetch+cache subsystem. Order is stable for reproducible
// admin-UI rendering.
//
// All eight HTML tools belong here — including browse, form, and tables.
// Omitting any of them means the apps cascade (.zddc apps:) silently
// short-circuits to embedded for that name, defeating live-dev
// path-source overrides.
var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing", "browse", "form", "tables"}

// AppsDefaultKey is the special apps-map key that provides the baseline
// URL prefix and channel for any app not overridden per-name. Cascades
// through .zddc files like a per-app entry.
const AppsDefaultKey = "default"

// 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
}

// IsValidAppsKey reports whether name is acceptable as a key in the
// `apps:` map — either a canonical app or the special "default" key.
func IsValidAppsKey(name string) bool {
	return name == AppsDefaultKey || IsKnownApp(name)
}

// 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
}

// ValidateAppSourceSpec returns nil if spec is a syntactically well-formed
// source spec accepted by apps.ParseSpec. It checks the string shape only —
// it does not verify URLs are reachable or paths exist.
//
// Accepted forms:
//   - "stable" / "beta" / "alpha" / ":stable" / ":beta" / ":alpha"   (channel)
//   - "v0.0.4" / "0.0.4" / "v0.0" / "0.0" / "v0" / "0" / ":v0.0.4"  (version)
//   - "https://host/path"                                            (URL prefix)
//   - "https://host/path:stable"                                     (URL prefix + channel)
//   - "https://host/path/file.html"                                  (terminal full URL)
//   - "/abs/path.html" / "./rel/path.html" / "../sibling.html"       (path)
func ValidateAppSourceSpec(spec string) error {
	if spec == "" {
		return fmt.Errorf("source spec is empty")
	}
	if strings.ContainsAny(spec, " \t\n\r") {
		return fmt.Errorf("source spec contains whitespace")
	}

	// Path forms.
	if strings.HasPrefix(spec, "/") ||
		strings.HasPrefix(spec, "./") ||
		strings.HasPrefix(spec, "../") {
		return nil
	}

	// URL forms.
	if strings.HasPrefix(spec, "https://") || strings.HasPrefix(spec, "http://") {
		return validateURLSpec(spec)
	}

	// Channel-or-version (with optional leading colon).
	chanPart := strings.TrimPrefix(spec, ":")
	if chanPart == "" {
		return fmt.Errorf("empty channel after ':'")
	}
	return validateChannelOrVersion(chanPart)
}

// validateURLSpec checks the URL-prefix or full-URL form. Splits on the
// last `:` after the last `/` (matching apps.parseURLSpec behavior).
func validateURLSpec(spec string) error {
	// Minimal sanity check on URL shape.
	if len(spec) <= len("https://") {
		return fmt.Errorf("URL is missing host")
	}
	lastSlash := strings.LastIndex(spec, "/")
	if lastSlash < 0 {
		return fmt.Errorf("invalid URL %q: missing path separator", spec)
	}
	afterSlash := spec[lastSlash+1:]
	colonInTail := strings.LastIndex(afterSlash, ":")
	urlPart, suffixPart := spec, ""
	if colonInTail >= 0 {
		urlPart = spec[:lastSlash+1+colonInTail]
		suffixPart = afterSlash[colonInTail+1:]
	}
	if strings.HasSuffix(urlPart, ".html") {
		if suffixPart != "" {
			return fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
		}
		return nil // terminal full URL
	}
	if suffixPart != "" {
		return validateChannelOrVersion(suffixPart)
	}
	return nil // URL-prefix only
}

// validateChannelOrVersion enforces the channel/version shape.
func validateChannelOrVersion(s string) error {
	if s == "stable" || s == "beta" || s == "alpha" {
		return nil
	}
	rest := strings.TrimPrefix(s, "v")
	if rest == "" {
		return fmt.Errorf("unrecognized source spec %q", s)
	}
	parts := strings.Split(rest, ".")
	if len(parts) > 3 {
		return fmt.Errorf("version has too many dots: %q", s)
	}
	for _, p := range parts {
		if p == "" {
			return fmt.Errorf("version has empty component: %q", s)
		}
		for _, r := range p {
			if r < '0' || r > '9' {
				return fmt.Errorf("unrecognized source spec %q", s)
			}
		}
	}
	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(),
				})
			}
		}
	}
	check("acl.allow", zf.ACL.Allow)
	check("acl.deny", zf.ACL.Deny)
	check("admins", zf.Admins)
	if len(zf.Title) > 200 {
		errs = append(errs, FieldError{
			Field:   "title",
			Message: "title exceeds 200 characters",
		})
	}
	for app, spec := range zf.Apps {
		if !IsValidAppsKey(app) {
			errs = append(errs, FieldError{
				Field:   fmt.Sprintf("apps.%s", app),
				Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing, browse, form, tables)", app),
			})
			continue
		}
		if err := ValidateAppSourceSpec(spec); err != nil {
			errs = append(errs, FieldError{
				Field:   fmt.Sprintf("apps.%s", app),
				Message: err.Error(),
			})
		}
	}
	return errs
}
