package handler

import (
	"encoding/json"
	"errors"
	"net/http"
	"os"
	"path/filepath"
	"strings"

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

// ZddcProfilePathPrefix is the URL prefix for the .zddc editor (both API and
// HTML page). All routes under this prefix require either super-admin
// authority (IsAdmin) or some subtree-admin grant; non-admins-of-anything
// receive 404 to keep editor existence hidden, matching the /.profile gate.
const ZddcProfilePathPrefix = ProfilePathPrefix + "/zddc"

// ServeZddc dispatches all /.profile/zddc/* routes. ServeProfile already
// trimmed the /.profile prefix; this handler is reachable for any admin
// (super or subtree), so it re-checks authorization itself rather than
// inheriting one from the caller.
//
// Sub-routes:
//
//	GET    /.profile/zddc?path=<dir>          → JSON: parsed file + chain
//	POST   /.profile/zddc?path=<dir>          → write (JSON body)
//	DELETE /.profile/zddc?path=<dir>          → remove file
//	GET    /.profile/zddc/tree                → JSON: list of editable dirs
//	GET    /.profile/zddc/edit?path=<dir>     → server-rendered editor page
func ServeZddc(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	email := EmailFromContext(r)

	// Hide existence from anyone who has no admin authority anywhere.
	if !hasAnyAdminScope(cfg.Root, email) {
		http.NotFound(w, r)
		return
	}

	// r.URL.Path is the full URL path; sub-route is everything after
	// /.profile/zddc.
	sub := strings.TrimPrefix(r.URL.Path, ZddcProfilePathPrefix)

	switch {
	case sub == "" || sub == "/":
		serveZddcAPI(cfg, w, r)
	case sub == "/tree":
		serveZddcTree(cfg, w, r)
	case sub == "/edit":
		serveZddcEditor(cfg, w, r)
	case strings.HasPrefix(sub, "/assets/"):
		serveZddcAssets(cfg, w, r)
	default:
		http.NotFound(w, r)
	}
}

// hasAnyAdminScope reports whether email has admin authority anywhere in
// the tree (root super-admin OR a subtree-admin grant on any .zddc).
// This is the gate for surfacing the editor at all.
func hasAnyAdminScope(fsRoot, email string) bool {
	if email == "" {
		return false
	}
	if zddc.IsAdmin(fsRoot, email) {
		return true
	}
	dirs, _ := zddc.ScanZddcFiles(fsRoot)
	for _, d := range dirs {
		if zddc.IsSubtreeAdmin(fsRoot, d, email) {
			return true
		}
	}
	return false
}

// resolvePath translates a URL `path=` query (relative to fsRoot, with
// '/' separator and leading '/') into an absolute filesystem path. It
// rejects path traversal and any segment beginning with '.' or '_' so
// reserved namespaces (e.g. .devshell) are not editable through this
// API. Returns the cleaned absolute path or an error suitable for a 404.
func resolvePath(fsRoot, urlPath string) (string, error) {
	urlPath = strings.TrimSpace(urlPath)
	if urlPath == "" {
		urlPath = "/"
	}
	if !strings.HasPrefix(urlPath, "/") {
		return "", errors.New("path must be absolute (start with /)")
	}
	cleanURL := filepath.ToSlash(filepath.Clean(urlPath))

	// Reject reserved-prefix segments so the editor cannot create
	// .foo/.zddc or _bar/.zddc through the API.
	for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
		if seg == "" {
			continue
		}
		if strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
			return "", errors.New("reserved-prefix path segment")
		}
	}

	rel := strings.TrimPrefix(cleanURL, "/")
	abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
	abs = filepath.Clean(abs)

	// Path containment.
	if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
		return "", errors.New("path escapes root")
	}
	return abs, nil
}

// urlPathOf produces the URL form of an absolute filesystem path under
// fsRoot. The result is "/" for fsRoot itself, otherwise "/<rel>".
func urlPathOf(fsRoot, abs string) string {
	if abs == fsRoot {
		return "/"
	}
	rel, err := filepath.Rel(fsRoot, abs)
	if err != nil {
		return "/"
	}
	return "/" + filepath.ToSlash(rel)
}

// chainEntry is one level of the effective-chain in API responses.
type chainEntry struct {
	Dir    string        `json:"dir"`
	Exists bool          `json:"exists"`
	Title  string        `json:"title,omitempty"`
	ACL    zddc.ACLRules `json:"acl"`
	Admins []string      `json:"admins,omitempty"`
}

type zddcGetResponse struct {
	Path           string        `json:"path"`
	Exists         bool          `json:"exists"`
	IsRoot         bool          `json:"is_root"`
	CanEdit        bool          `json:"can_edit"`
	File           zddc.ZddcFile `json:"file"`
	EffectiveChain []chainEntry  `json:"effective_chain"`
}

type zddcWriteRequest struct {
	Title  string            `json:"title"`
	ACL    zddc.ACLRules     `json:"acl"`
	Admins []string          `json:"admins"`
	Apps   map[string]string `json:"apps,omitempty"`
}

type writeError struct {
	Errors []zddc.FieldError `json:"errors"`
}

// serveZddcAPI handles /.profile/zddc?path=<dir> for GET, POST, DELETE.
func serveZddcAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	email := EmailFromContext(r)
	abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
	if err != nil {
		http.NotFound(w, r)
		return
	}

	switch r.Method {
	case http.MethodGet:
		serveZddcGet(cfg, abs, email, w, r)
	case http.MethodPost, http.MethodPut:
		serveZddcWrite(cfg, abs, email, w, r)
	case http.MethodDelete:
		serveZddcDelete(cfg, abs, email, w, r)
	default:
		w.Header().Set("Allow", "GET, POST, DELETE")
		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
	}
}

func serveZddcGet(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
	zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
	if err != nil {
		http.Error(w, "Bad Request: cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
		return
	}
	exists := false
	if _, statErr := os.Stat(filepath.Join(abs, ".zddc")); statErr == nil {
		exists = true
	}

	chain, _ := zddc.EffectivePolicy(cfg.Root, abs)
	dirs := chainDirs(cfg.Root, abs)
	entries := make([]chainEntry, 0, len(chain.Levels))
	for i, level := range chain.Levels {
		levelDir := dirs[i]
		levelExists := false
		if _, err := os.Stat(filepath.Join(levelDir, ".zddc")); err == nil {
			levelExists = true
		}
		entries = append(entries, chainEntry{
			Dir:    urlPathOf(cfg.Root, levelDir),
			Exists: levelExists,
			Title:  level.Title,
			ACL:    level.ACL,
			Admins: level.Admins,
		})
	}

	resp := zddcGetResponse{
		Path:           urlPathOf(cfg.Root, abs),
		Exists:         exists,
		IsRoot:         abs == cfg.Root,
		CanEdit:        zddc.CanEditZddc(cfg.Root, abs, email),
		File:           zf,
		EffectiveChain: entries,
	}
	writeJSON(w, resp)
}

// chainDirs reproduces EffectivePolicy's directory walk so the chainEntry
// list can carry the directory of each level. Kept local to this file to
// avoid widening the zddc package's API.
func chainDirs(fsRoot, dirPath string) []string {
	fsRoot = filepath.Clean(fsRoot)
	dirPath = filepath.Clean(dirPath)
	dirs := []string{fsRoot}
	if dirPath == fsRoot {
		return dirs
	}
	rel, err := filepath.Rel(fsRoot, dirPath)
	if err != nil || rel == "." {
		return dirs
	}
	current := fsRoot
	for _, part := range strings.Split(rel, string(filepath.Separator)) {
		current = filepath.Join(current, part)
		dirs = append(dirs, current)
	}
	return dirs
}

func serveZddcWrite(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
	if !zddc.CanEditZddc(cfg.Root, abs, email) {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return
	}
	if err := os.MkdirAll(abs, 0o755); err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	defer r.Body.Close()
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	var req zddcWriteRequest
	if err := dec.Decode(&req); err != nil {
		http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
		return
	}

	zf := zddc.ZddcFile{
		Title:  req.Title,
		ACL:    req.ACL,
		Admins: req.Admins,
		Apps:   req.Apps,
	}

	if errs := zddc.ValidateFile(zf); len(errs) > 0 {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		w.WriteHeader(http.StatusBadRequest)
		_ = json.NewEncoder(w).Encode(writeError{Errors: errs})
		return
	}

	// Root-only invariant: writer must remain in the post-write Admins
	// list. Recovery requires filesystem access we don't have.
	if abs == cfg.Root {
		stillAdmin := false
		for _, p := range zf.Admins {
			if zddc.MatchesPattern(p, email) {
				stillAdmin = true
				break
			}
		}
		if !stillAdmin {
			w.Header().Set("Content-Type", "application/json; charset=utf-8")
			w.WriteHeader(http.StatusBadRequest)
			_ = json.NewEncoder(w).Encode(writeError{Errors: []zddc.FieldError{{
				Field:   "admins",
				Message: "you cannot remove yourself from the root admins list",
			}}})
			return
		}
	}

	if err := zddc.WriteFile(abs, zf); err != nil {
		http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
		return
	}
	writeJSON(w, map[string]any{"ok": true, "path": urlPathOf(cfg.Root, abs)})
}

func serveZddcDelete(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
	if !zddc.CanEditZddc(cfg.Root, abs, email) {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return
	}
	// Root-only invariant: cannot delete root .zddc, that would leave no
	// way to administer the server (and CanEditZddc(root) would return
	// false on any subsequent request).
	if abs == cfg.Root {
		http.Error(w, "Cannot delete root .zddc — edit instead", http.StatusBadRequest)
		return
	}
	if err := zddc.DeleteFile(abs); err != nil {
		http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

type treeEntry struct {
	Path    string `json:"path"`
	CanEdit bool   `json:"can_edit"`
	Title   string `json:"title,omitempty"`
}

// serveZddcTree returns the list of every directory containing a .zddc
// file, with a per-entry can_edit flag. The list is filtered to entries
// the caller has at least admin visibility for (read or edit).
func serveZddcTree(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	email := EmailFromContext(r)
	dirs, _ := zddc.ScanZddcFiles(cfg.Root)
	out := make([]treeEntry, 0, len(dirs))
	for _, d := range dirs {
		if !zddc.IsSubtreeAdmin(cfg.Root, d, email) && !zddc.IsAdmin(cfg.Root, email) {
			continue
		}
		var title string
		if zf, err := zddc.ParseFile(filepath.Join(d, ".zddc")); err == nil {
			title = zf.Title
		}
		out = append(out, treeEntry{
			Path:    urlPathOf(cfg.Root, d),
			CanEdit: zddc.CanEditZddc(cfg.Root, d, email),
			Title:   title,
		})
	}
	writeJSON(w, out)
}
