// Package apps serves the five ZDDC tool HTML files (archive, transmittal,
// classifier, mdedit, landing) on virtual paths in the file tree. Each tool
// is "available" only at directories whose name matches a folder convention
// (Incoming/Working/Staging) — see availability.go.
//
// Resolution priority for an enabled <dir>/<app>.html request:
//
//  1. Real file at the request path → static handler (operator override).
//  2. Subdir cascade — walk .zddc files root→leaf, accumulating URL prefix
//     and channel/version components from the special `apps.default` key
//     and the per-app `apps.<name>` key. Either component can be set,
//     overridden, or left to inherit at any level. Path or full-`.html`-URL
//     entries are *terminal* — they short-circuit composition and a deeper
//     non-terminal entry overrides a parent terminal.
//  3. Embedded fallback — bytes baked into the binary at compile time via
//     //go:embed. Used when no `apps:` entry was found anywhere up the chain.
//
// Spec forms (each is a string value in `.zddc apps:`):
//
//   :stable / :beta / :alpha / :v0.0.4 / :v0.0 / :v0   — channel-only
//   stable / beta / alpha / v0.0.4 / v0.0 / v0          — channel-only (no leading colon)
//   https://host/path                                    — URL-prefix only (combines with cascade channel)
//   https://host/path:stable                             — URL-prefix + channel (composes)
//   https://host/path/file.html                          — terminal full URL (used as-is)
//   ./local.html / /abs/local.html                       — terminal local path
//
// No background refresh, no SHA-256 verification. To pick up new upstream
// bytes, delete the cache file (or the whole _app/ tree).
package apps

import (
	"fmt"
	"net/url"
	"path/filepath"
	"strings"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)

// DefaultUpstream is where channel and version shorthand specs resolve when
// no `apps.default` URL prefix is configured anywhere up the chain.
const DefaultUpstream = "https://zddc.varasys.io"

// DefaultUpstreamReleases is the prefix appended to DefaultUpstream when
// composing the canonical upstream URL.
const DefaultUpstreamReleases = DefaultUpstream + "/releases"

// DefaultChannel is the channel shorthand used when nothing in the chain
// specifies one.
const DefaultChannel = "stable"

// CacheDirName is the directory under ZDDC_ROOT where fetched URL sources
// are cached. The leading underscore excludes it from project listings;
// dispatch additionally blocks direct URL access.
const CacheDirName = "_app"

// DefaultAppsKey is the special key in `apps:` that provides the baseline
// URL prefix and channel for any app not overridden per-name. Cascades
// through .zddc files like everything else.
const DefaultAppsKey = "default"

// Source is a fully-resolved app source (output of Resolve).
type Source struct {
	App  string // canonical app name
	URL  string // upstream URL (mutually exclusive with Path)
	Path string // resolved local file path
}

// IsURL reports whether the source is fetched (vs read from disk).
func (s Source) IsURL() bool { return s.URL != "" }

// SpecComponents is the parsed shape of a single `.zddc apps:` value.
// Terminal forms (Path or FullURL) are mutually exclusive with the
// composable URLPrefix/Channel forms. Resolve() turns one or more
// SpecComponents (one per applicable level in the cascade) into a final
// Source.
type SpecComponents struct {
	// Terminal forms — exactly one set means the spec is terminal and
	// short-circuits composition.
	Path    string // local file path (resolved + bounded to ZDDC_ROOT)
	FullURL string // full URL ending in `.html` (used as-is)

	// Composable forms — either or both may be set, both may be empty
	// (caller should treat empty-everything as a no-op).
	URLPrefix string // "https://host/path" (no trailing /)
	Channel   string // "stable" / "beta" / "alpha" / "v0.0.4" / "v0.0" / "v0"
}

// IsTerminal reports whether this spec terminates composition.
func (c SpecComponents) IsTerminal() bool {
	return c.Path != "" || c.FullURL != ""
}

// IsEmpty reports whether the spec contributes nothing to composition.
func (c SpecComponents) IsEmpty() bool {
	return c.Path == "" && c.FullURL == "" && c.URLPrefix == "" && c.Channel == ""
}

// ParseSpec parses one `.zddc apps:` value into components.
// zddcDir anchors relative paths; root bounds path-traversal.
func ParseSpec(spec, zddcDir, root string) (SpecComponents, error) {
	spec = strings.TrimSpace(spec)
	if spec == "" {
		return SpecComponents{}, fmt.Errorf("source spec is empty")
	}

	// Path forms — terminal.
	if strings.HasPrefix(spec, "/") ||
		strings.HasPrefix(spec, "./") ||
		strings.HasPrefix(spec, "../") {
		var abs string
		if filepath.IsAbs(spec) {
			abs = filepath.Clean(spec)
		} else {
			abs = filepath.Clean(filepath.Join(zddcDir, spec))
		}
		rootClean := filepath.Clean(root)
		if abs != rootClean && !strings.HasPrefix(abs, rootClean+string(filepath.Separator)) {
			return SpecComponents{}, fmt.Errorf("path %q escapes ZDDC_ROOT", spec)
		}
		return SpecComponents{Path: abs}, nil
	}

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

	// Channel-only forms: ":channel" or bare "channel".
	chanPart := strings.TrimPrefix(spec, ":")
	if chanPart == "" {
		return SpecComponents{}, fmt.Errorf("empty channel after ':'")
	}
	if !isValidChannelOrVersion(chanPart) {
		return SpecComponents{}, fmt.Errorf("unrecognized source spec %q (expected channel, version, URL, or path)", spec)
	}
	return SpecComponents{Channel: normalizeChannel(chanPart)}, nil
}

// parseURLSpec splits a URL spec into prefix vs full-URL based on the
// last `:` after the last `/`. Examples:
//
//	https://host:8080/path:stable    → URLPrefix=https://host:8080/path, Channel=stable
//	https://host:8080/path           → URLPrefix=https://host:8080/path
//	https://host/path/file.html      → FullURL=https://host/path/file.html (terminal)
//	https://host/path/file.html:beta → error (terminal URL with extra suffix)
func parseURLSpec(spec string) (SpecComponents, error) {
	// Locate the channel separator: last `:` that comes after the last `/`.
	lastSlash := strings.LastIndex(spec, "/")
	if lastSlash < 0 {
		return SpecComponents{}, 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:]
	}

	// Validate the URL portion.
	u, err := url.Parse(urlPart)
	if err != nil {
		return SpecComponents{}, fmt.Errorf("invalid URL %q: %w", urlPart, err)
	}
	if u.Host == "" {
		return SpecComponents{}, fmt.Errorf("URL %q is missing host", urlPart)
	}

	// Terminal full URL: ends in `.html`. A `:suffix` on a `.html` URL is
	// rejected to prevent silent misinterpretation.
	if strings.HasSuffix(urlPart, ".html") {
		if suffixPart != "" {
			return SpecComponents{}, fmt.Errorf("URL ends in .html but has %q suffix", ":"+suffixPart)
		}
		return SpecComponents{FullURL: urlPart}, nil
	}

	// URL-prefix form. Strip trailing slash for normalization.
	prefix := strings.TrimRight(urlPart, "/")

	out := SpecComponents{URLPrefix: prefix}
	if suffixPart != "" {
		if !isValidChannelOrVersion(suffixPart) {
			return SpecComponents{}, fmt.Errorf("invalid channel/version suffix %q", suffixPart)
		}
		out.Channel = normalizeChannel(suffixPart)
	}
	return out, nil
}

// isValidChannelOrVersion reports whether s is `stable`/`beta`/`alpha` or a
// version like `v0.0.4`/`0.0.4`/`v0.0`/`v0`.
func isValidChannelOrVersion(s string) bool {
	if s == "stable" || s == "beta" || s == "alpha" {
		return true
	}
	rest := strings.TrimPrefix(s, "v")
	if rest == "" {
		return false
	}
	parts := strings.Split(rest, ".")
	if len(parts) > 3 {
		return false
	}
	for _, p := range parts {
		if p == "" {
			return false
		}
		for _, r := range p {
			if r < '0' || r > '9' {
				return false
			}
		}
	}
	return true
}

// normalizeChannel ensures versions carry the `v` prefix (so the resulting
// filename is `<app>_v<X.Y.Z>.html` per upstream convention).
func normalizeChannel(s string) string {
	if s == "stable" || s == "beta" || s == "alpha" {
		return s
	}
	if !strings.HasPrefix(s, "v") {
		return "v" + s
	}
	return s
}

// Resolve walks the .zddc chain root→leaf, applying `apps.default` and
// `apps.<app>` at each level. Returns the resolved Source and true if any
// entry contributed; (Source{}, false, nil) means no override (caller
// serves embedded). On malformed spec, returns an error.
func Resolve(chain zddc.PolicyChain, app, root, requestDir string) (Source, bool, error) {
	return ResolveWithOverride(chain, app, root, requestDir, "")
}

// ResolveWithOverride is Resolve with an additional per-request override
// applied as one final cascade level after the .zddc chain. Used to honor
// the `?v=` query parameter on tool HTML requests.
//
// vSpec accepts the same syntax as `.zddc apps:` values (channel/version,
// `:channel`, URL prefix, `url:channel`, full `.html` URL). Path sources
// are rejected (security: `?v=` must resolve to a URL whose bytes the
// caller can fetch from cache only).
//
// Empty vSpec is equivalent to plain Resolve.
func ResolveWithOverride(chain zddc.PolicyChain, app, root, requestDir, vSpec string) (Source, bool, error) {
	app = strings.ToLower(strings.TrimSpace(app))
	if !zddc.IsKnownApp(app) {
		return Source{}, false, fmt.Errorf("unknown app %q", app)
	}

	dirs := walkDirs(root, requestDir)

	st := newAppsState(app)

	// Walk root → leaf.
	for i := 0; i < len(chain.Levels); i++ {
		lvl := chain.Levels[i]
		dir := root
		if i < len(dirs) {
			dir = dirs[i]
		}
		// `default` first, then per-app override at the same level.
		if spec, ok := lvl.Apps[DefaultAppsKey]; ok && spec != "" {
			if err := st.apply(spec, dir, root, "apps.default"); err != nil {
				return Source{}, false, err
			}
		}
		if spec, ok := lvl.Apps[app]; ok && spec != "" {
			if err := st.apply(spec, dir, root, "apps."+app); err != nil {
				return Source{}, false, err
			}
		}
	}

	// Per-request override (`?v=`): one final layer.
	if vSpec = strings.TrimSpace(vSpec); vSpec != "" {
		comp, err := ParseSpec(vSpec, requestDir, root)
		if err != nil {
			return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err)
		}
		// Reject path sources from per-request override — security: we serve
		// only what the cache (populated by .zddc-controlled fetches) holds.
		if comp.Path != "" {
			return Source{}, false, fmt.Errorf("?v= cannot specify a local path source")
		}
		if err := st.applyComponents(comp); err != nil {
			return Source{}, false, fmt.Errorf("?v=%s: %w", vSpec, err)
		}
	}

	return st.finalize()
}

// appsState accumulates URL-prefix and channel components across cascade
// levels, with terminal-source short-circuit semantics.
type appsState struct {
	app         string
	haveAny     bool
	urlPrefix   string
	channel     string
	terminalSrc *Source
}

func newAppsState(app string) *appsState {
	return &appsState{app: app}
}

func (s *appsState) apply(spec, zddcDir, root, label string) error {
	comp, err := ParseSpec(spec, zddcDir, root)
	if err != nil {
		return fmt.Errorf("%s: %w", label, err)
	}
	return s.applyComponents(comp)
}

func (s *appsState) applyComponents(comp SpecComponents) error {
	if comp.IsEmpty() {
		return nil
	}
	s.haveAny = true
	switch {
	case comp.Path != "":
		s.terminalSrc = &Source{App: s.app, Path: comp.Path}
		s.urlPrefix, s.channel = "", ""
	case comp.FullURL != "":
		s.terminalSrc = &Source{App: s.app, URL: comp.FullURL}
		s.urlPrefix, s.channel = "", ""
	default:
		// Non-terminal: deeper non-terminal entries override a parent terminal.
		s.terminalSrc = nil
		if comp.URLPrefix != "" {
			s.urlPrefix = comp.URLPrefix
		}
		if comp.Channel != "" {
			s.channel = comp.Channel
		}
	}
	return nil
}

func (s *appsState) finalize() (Source, bool, error) {
	if !s.haveAny {
		return Source{}, false, nil
	}
	if s.terminalSrc != nil {
		return *s.terminalSrc, true, nil
	}
	urlPrefix := s.urlPrefix
	if urlPrefix == "" {
		urlPrefix = DefaultUpstreamReleases
	}
	channel := s.channel
	if channel == "" {
		channel = DefaultChannel
	}
	return Source{
		App: s.app,
		URL: urlPrefix + "/" + s.app + "_" + channel + ".html",
	}, true, nil
}

// PreviewLine returns a short human-readable description of how an app
// resolves at requestDir given the chain. Used by the .zddc editor to
// render a "Resolves to: ..." line beside each apps input.
func PreviewLine(chain zddc.PolicyChain, app, root, requestDir string) string {
	src, has, err := Resolve(chain, app, root, requestDir)
	if err != nil {
		return "error: " + err.Error()
	}
	if !has {
		return "embedded (build-time default)"
	}
	if src.Path != "" {
		return "local file: " + src.Path
	}
	return src.URL
}

func walkDirs(root, requestDir string) []string {
	root = filepath.Clean(root)
	requestDir = filepath.Clean(requestDir)
	if requestDir == root {
		return []string{root}
	}
	rel, err := filepath.Rel(root, requestDir)
	if err != nil {
		return []string{root}
	}
	dirs := []string{root}
	cur := root
	for _, part := range strings.Split(rel, string(filepath.Separator)) {
		cur = filepath.Join(cur, part)
		dirs = append(dirs, cur)
	}
	return dirs
}
