package handler

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"strings"

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

// ProjectInfo is a single entry in the project list response.
//
// Title is read from the project's own .zddc file (its `title:` field) when
// present; absent or empty means the landing page shows just the directory
// name. omitempty keeps the JSON small for projects without titles.
type ProjectInfo struct {
	Name  string `json:"name"`
	URL   string `json:"url"`
	Title string `json:"title,omitempty"`
}

// ServeProjectList handles GET / with Accept: application/json.
// It returns all top-level directories under cfg.Root that the requesting
// user has access to, as a JSON array of ProjectInfo.
//
// Response carries a content-hash ETag. The landing page polls this
// endpoint on every paint, and the response (a small JSON array of
// project names + URLs the caller can reach) rarely changes between
// polls, so 304s save a meaningful amount of cumulative bandwidth.
// The hash is computed from the actual response body, so it tolerates
// unreliable filesystem watching.
func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	projects, err := EnumerateProjects(r.Context(), DeciderFromContext(r), cfg, EmailFromContext(r))
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	body, err := json.Marshal(projects)
	if err != nil {
		slog.Error("encoding project list", "err", err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	etag := `"` + listingETag(body) + `"`
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("ETag", etag)
	w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
	if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
		w.WriteHeader(http.StatusNotModified)
		return
	}
	_, _ = w.Write(body)
}

// EnumerateProjects returns the visible top-level projects for the given
// caller, reusing the same access logic as ServeProjectList. Exported so
// the profile page can render the same list server-side without an HTTP
// round-trip. A nil decider falls back to the internal Go evaluator.
func EnumerateProjects(ctx context.Context, decider policy.Decider, cfg config.Config, email string) ([]ProjectInfo, error) {
	if decider == nil {
		decider = &policy.InternalDecider{}
	}
	entries, err := os.ReadDir(cfg.Root)
	if err != nil {
		slog.Error("reading root directory", "err", err)
		return nil, err
	}

	var projects []ProjectInfo
	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		name := entry.Name()
		// Skip hidden directories. Both '.' and '_' are reserved prefixes:
		// '.' for system/internal state (matches the listing-pipeline filter
		// and the dispatch dot-prefix guard); '_' for operator-managed
		// scaffolding like install.zip's _template/ directory that should
		// be reachable by direct URL but not appear in the project picker.
		if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
			continue
		}
		absPath := filepath.Join(cfg.Root, name)
		chain, err := zddc.EffectivePolicy(cfg.Root, absPath)
		if err != nil {
			slog.Warn("ACL policy error", "path", absPath, "err", err)
		}
		if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, "/"+name+"/"); !allowed {
			continue
		}
		// Title comes from <project>/.zddc — optional, ignored on parse error.
		var title string
		if zf, err := zddc.ParseFile(filepath.Join(absPath, ".zddc")); err == nil {
			title = zf.Title
		}
		projects = append(projects, ProjectInfo{
			Name:  name,
			URL:   "/" + name + "/",
			Title: title,
		})
	}
	return projects, nil
}
