package apps

import (
	"crypto/sha256"
	"encoding/hex"
	"log/slog"
	"net/http"
	"path/filepath"
	"strings"

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

// Server resolves tool HTML for a request: bundle member → embedded. The
// on-disk-at-path tier (operator override) is handled UPSTREAM by the
// dispatcher's stat-first static handler, so by the time Serve runs no real
// file exists at the path. Server does NOT decide whether the app is
// available at the directory — that's AppAvailableAt's job, called from
// dispatch before Serve.
type Server struct {
	Root     string
	BuildVer string // baked into X-ZDDC-Source for embedded responses
	Bundle   *Bundle
	Logger   *slog.Logger
}

// NewServer constructs a Server bound to the site-root config bundle.
func NewServer(root, buildVer string) *Server {
	root = filepath.Clean(root)
	logger := slog.Default()
	return &Server{
		Root:     root,
		BuildVer: buildVer,
		Bundle:   NewBundle(root, logger),
		Logger:   logger,
	}
}

// MatchAppHTML returns the canonical app name if requestPath matches a
// "<dir>/<app>.html" pattern for one of the canonical apps, plus the
// directory (relative to root) the request is rooted at. The cmd/zddc-
// server dispatcher calls this when stat fails on a URL: a missing file
// that happens to look like `<dir>/archive.html` (or browse.html, etc.)
// resolves to the embedded (or bundle) app HTML for that directory —
// operators don't have to copy app HTML into every project.
//
// Special case: GET / and GET /index.html both resolve to landing — the
// only entry point that scopes ACL per-project, and the conventional
// place for a static-site index when an operator wants one.
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 "browse.html":
		return "browse", dir
	}
	return "", ""
}

// resolveBytes applies the local override precedence (tiers 2 then 3; tier 1
// is handled upstream). Returns the HTML body, the X-ZDDC-Source tag, and
// whether to use the memoized embedded ETag (vs a body-hash ETag).
func (s *Server) resolveBytes(app string) (body []byte, sourceTag string, embedded, ok bool) {
	if s.Bundle != nil {
		if b, found := s.Bundle.Member(app + ".html"); found {
			return b, "bundle:" + app + ".html", false, true
		}
	}
	if b := EmbeddedBytes(app); len(b) > 0 {
		return b, "embedded:" + app + "@" + s.BuildVer, true, true
	}
	return nil, "", false, false
}

// Serve resolves and writes the response. Caller has already verified:
//   - no real file exists at the request path (so tier 1 didn't apply)
//   - AppAvailableAt(root, requestDir, app) is true
//   - ACL passes for requestDir
//
// chain and requestDir are retained in the signature for call-site stability
// and future per-directory resolution; the current local model is path-
// independent (a bundle member or the embedded default).
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, _ zddc.PolicyChain, _ string) {
	body, tag, embedded, ok := s.resolveBytes(app)
	if !ok {
		w.Header().Set("Retry-After", "60")
		http.Error(w,
			"503 Service Unavailable\n\n"+
				"This zddc-server has no embedded fallback for "+app+" and no\n"+
				"\""+app+".html\" in the site .zddc.zip bundle.\n"+
				"Rebuild the binary against the latest tool HTMLs, or add the\n"+
				"file to .zddc.zip.\n",
			http.StatusServiceUnavailable)
		return
	}
	etag := bodyETag(body)
	if embedded {
		etag = EmbeddedETag(app)
	}
	writeWithETag(w, r, body, etag, "text/html; charset=utf-8", tag)
}

// writeWithETag writes body with a strong ETag, cache-friendly headers, and
// short-circuits to 304 Not Modified when the client's If-None-Match matches.
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.
func bodyETag(body []byte) string {
	sum := sha256.Sum256(body)
	return hex.EncodeToString(sum[:])[:32]
}
