package apps

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"net/http"
	"os"
	"path/filepath"
	"strings"

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

// Server orchestrates app HTML resolution: subdir cascade override → fetch
// or path read → embedded fallback. It does NOT check whether the app is
// available at the request directory — that's AppAvailableAt's job, called
// from dispatch before invoking Serve.
type Server struct {
	Root     string
	Cache    *Cache
	Fetcher  *Fetcher
	BuildVer string // baked into X-ZDDC-Source for embedded responses
}

// NewServer constructs a Server.
func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Server {
	return &Server{
		Root:     filepath.Clean(root),
		Cache:    cache,
		Fetcher:  fetcher,
		BuildVer: buildVer,
	}
}

// MatchAppHTML returns the canonical app name if requestPath matches a
// "<dir>/<app>.html" pattern for one of the five canonical apps, plus the
// directory (relative to root) the request is rooted at.
//
// Special case: GET / and GET /index.html both resolve to landing.
func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
	if requestPath == "" || requestPath == "/" {
		return "landing", ""
	}
	clean := strings.TrimPrefix(requestPath, "/")
	clean = strings.TrimSuffix(clean, "/")
	if clean == "" {
		return "landing", ""
	}
	dir := filepath.Dir(clean)
	if dir == "." {
		dir = ""
	}
	switch filepath.Base(clean) {
	case "index.html":
		return "landing", dir
	case "archive.html":
		return "archive", dir
	case "transmittal.html":
		return "transmittal", dir
	case "classifier.html":
		return "classifier", dir
	case "mdedit.html":
		return "mdedit", dir
	case "browse.html":
		return "browse", dir
	}
	return "", ""
}

// Serve resolves and writes the response. Caller has already verified:
//   - no real file exists at the request path
//   - AppAvailableAt(root, requestDir, app) is true
//   - ACL passes for requestDir
//
// Honors a `?v=<spec>` query parameter as a per-request override on top of
// the cascade. With `?v=` set, the resolved URL must already exist in the
// cache — otherwise the response is 404. This prevents users from
// triggering arbitrary upstream fetches via URL-crafted requests; only
// versions the operator's `.zddc apps:` entries have already pulled in
// (or that the user has manually placed in `_app/`) are reachable.
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain zddc.PolicyChain, requestDir string) {
	vSpec := strings.TrimSpace(r.URL.Query().Get("v"))

	src, hasOverride, err := ResolveWithOverride(chain, app, s.Root, requestDir, vSpec)
	if err != nil {
		// `?v=` parsing/validation errors are user input → 400.
		if vSpec != "" {
			http.Error(w, "400 Bad Request — invalid ?v= value: "+err.Error(), http.StatusBadRequest)
			return
		}
		// Malformed `.zddc` spec — operator's fault. Log and serve embedded.
		s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
			"app", app, "request_dir", requestDir, "err", err)
		s.serveEmbedded(w, r, app, err)
		return
	}

	if !hasOverride {
		// No `.zddc apps:` entry anywhere up the chain and no `?v=` either →
		// embedded is the authoritative default.
		s.serveEmbedded(w, r, app, nil)
		return
	}

	// Per-request `?v=` is restricted to cache-backed URL sources.
	if vSpec != "" {
		if !src.IsURL() {
			http.Error(w, "400 Bad Request — ?v= requires a URL-form spec", http.StatusBadRequest)
			return
		}
		if s.Cache == nil || !s.Cache.Has(src.URL) {
			http.Error(w,
				"404 Not Found — version requested via ?v= is not in the local cache.\n"+
					"Only versions the deployment has already fetched (via .zddc apps: entries) are servable.\n"+
					"Asked for: "+src.URL+"\n",
				http.StatusNotFound)
			return
		}
		body, err := s.Cache.Read(src.URL)
		if err != nil {
			s.Fetcher.Logger.Warn("?v= cache read failed", "url", src.URL, "err", err)
			http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
			return
		}
		s.serveBody(w, r, body, "cache:"+src.URL)
		return
	}

	if !src.IsURL() {
		// Path source: read directly, no cache.
		body, err := os.ReadFile(src.Path)
		if err != nil {
			if errors.Is(err, os.ErrNotExist) {
				s.Fetcher.Logger.Warn("path source missing; serving embedded",
					"app", app, "path", src.Path)
			} else {
				s.Fetcher.Logger.Warn("path source unreadable; serving embedded",
					"app", app, "path", src.Path, "err", err)
			}
			s.serveEmbedded(w, r, app, err)
			return
		}
		s.serveBody(w, r, body, "path:"+src.Path)
		return
	}

	// URL source: cache hit serves immediately; cache miss fetches once.
	body, err := s.Fetcher.Fetch(r.Context(), src.URL)
	if err != nil {
		s.Fetcher.LogEmbeddedFallback(app, src.URL, err)
		s.serveEmbedded(w, r, app, err)
		return
	}
	sourceTag := "fetch:" + src.URL
	if s.Cache != nil && s.Cache.Has(src.URL) {
		// Likely served from cache (Has was true when the read started).
		// Distinguishing cache-hit from just-fetched is best-effort here.
		sourceTag = "cache:" + src.URL
	}
	s.serveBody(w, r, body, sourceTag)
}

// writeWithETag writes body with a strong ETag derived from `etag`, the
// cache-friendly headers, and short-circuits to 304 Not Modified when the
// client's `If-None-Match` matches. `max-age=0, must-revalidate` means the
// browser revalidates on every load — and the matching ETag returns 304
// with empty body, so the steady-state cost of a reload is ~200 bytes
// instead of the full HTML payload (50–920 KB depending on the tool).
func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, contentType, sourceHeader string) {
	quotedTag := `"` + etag + `"`
	w.Header().Set("ETag", quotedTag)
	w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
	w.Header().Set("Content-Type", contentType)
	w.Header().Set("X-ZDDC-Source", sourceHeader)

	if match := r.Header.Get("If-None-Match"); match != "" && match == quotedTag {
		w.WriteHeader(http.StatusNotModified)
		return
	}
	_, _ = w.Write(body)
}

// bodyETag computes a stable 32-hex-char ETag for an arbitrary body. Used
// for the URL/path-sourced response path (the bytes vary per cache-fetch
// or per file read, so memoizing per-app would be wrong).
func bodyETag(body []byte) string {
	sum := sha256.Sum256(body)
	return hex.EncodeToString(sum[:])[:32]
}

func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
	writeWithETag(w, r, body, bodyETag(body), "text/html; charset=utf-8", sourceHeader)
}

func (s *Server) serveEmbedded(w http.ResponseWriter, r *http.Request, app string, _ error) {
	body := EmbeddedBytes(app)
	if len(body) == 0 {
		w.Header().Set("Retry-After", "60")
		http.Error(w,
			"503 Service Unavailable\n\n"+
				"This zddc-server has no embedded fallback for "+app+".\n"+
				"Rebuild the binary against the latest tool HTMLs.\n",
			http.StatusServiceUnavailable)
		return
	}
	writeWithETag(w, r, body, EmbeddedETag(app),
		"text/html; charset=utf-8",
		"embedded:"+app+"@"+s.BuildVer)
}
