package main

import (
	"fmt"
	"io/fs"
	"log"
	"net/http"
	"os"
	"os/exec"
	"path"
	"sort"
	"strings"
	"sync/atomic"
)

// server holds the currently mounted exhibits behind an atomic pointer,
// so a config reload can swap the whole set at once while requests read it lock-free.
// Each request loads the pointer once and works with that immutable snapshot for its whole duration.
// (A RWMutex would also work, but there is nothing to lock *around*:
// the atomic pointer makes the publish-immutable-snapshots discipline explicit.)
type server struct {
	mounts atomic.Pointer[mountSet]
}

// mountSet maps mount names (URL prefixes) to opened trees.
// A mountSet is never mutated after being published to server.mounts.
type mountSet map[string]tree

// tree is one exhibit as served: an opened os.Root plus its options.
// os.Root guarantees all path resolution stays inside the tree
// (it rejects ".." escapes and symlinks pointing outside).
type tree struct {
	root      *os.Root
	exposeGit bool
}

func newServer(exhibits map[string]exhibit) (*server, error) {
	s := &server{}
	if err := s.setExhibits(exhibits); err != nil {
		return nil, err
	}
	return s, nil
}

// setExhibits builds a fresh mount set from the given exhibits and publishes it atomically.
// On any error (an exhibit dir that fails to open) nothing is published:
// at startup that is fail-loud (newServer returns the error),
// and on reload it is fail-stable (the previous mount set keeps serving untouched).
//
// The previous mount set's os.Root handles are deliberately not closed here.
// An in-flight request may have loaded the old set just before the swap
// and still be reading through one of its roots; closing would break it mid-response.
// os.Root carries a runtime finalizer that closes its fd once unreferenced,
// so old handles are reclaimed exactly when the last such request drops them.
// That is the simplest scheme that is actually correct:
// refcounting would buy only promptness,
// and a few directory fds lingering until the next GC cycle cost nothing.
func (s *server) setExhibits(exhibits map[string]exhibit) error {
	mounts := make(mountSet, len(exhibits))
	for name, ex := range exhibits {
		root, err := os.OpenRoot(ex.Path)
		if err != nil {
			// Nothing from this attempt was published, so closing is safe here.
			for _, tr := range mounts {
				tr.root.Close()
			}
			return fmt.Errorf("opening exhibit %q: %w", name, err)
		}
		mounts[name] = tree{root: root, exposeGit: ex.ExposeGit}
	}
	// Run update-server-info only once the whole set has validated:
	// a rejected config should leave no trace, not even freshened git metadata.
	// Re-running on every successful reload is deliberate and idempotent.
	// (Watching the git repos themselves for new commits is possible future work;
	// today a commit becomes clonable at the next config reload or restart.)
	for name, ex := range exhibits {
		if ex.ExposeGit {
			updateGitServerInfo(name, ex.Path)
		}
	}
	s.mounts.Store(&mounts)
	return nil
}

// updateGitServerInfo runs `git update-server-info` in the exhibit directory,
// regenerating .git/info/refs and .git/objects/info/packs so that dumb-HTTP
// clone sees current refs (ordinary commits never maintain those files;
// without them a clone either fails or silently delivers a stale tip).
//
// This writes into the exhibit's .git directory: a deliberate, opt-in exception
// to vitrine's otherwise strictly read-only stance toward the trees it serves.
// Failure is logged, not fatal: the exhibit may not be a git repo,
// and plain file serving should keep working regardless.
// Executing git from PATH is a conscious choice; vitrine is glue,
// and reimplementing the two info files buys nothing but maintenance.
func updateGitServerInfo(name, dir string) {
	cmd := exec.Command("git", "update-server-info")
	cmd.Dir = dir
	if out, err := cmd.CombinedOutput(); err != nil {
		log.Printf("exhibit %q: git update-server-info failed (dumb-HTTP clone may be stale or broken): %v: %s", name, err, strings.TrimSpace(string(out)))
	}
}

func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// The "/-/" prefix is reserved for server-internal assets,
	// keeping it distinct from any configured mount name.
	if r.URL.Path == "/-/style.css" {
		w.Header().Set("Content-Type", "text/css; charset=utf-8")
		w.Write(styleCSS)
		return
	}

	req := path.Clean("/" + r.URL.Path)[1:] // normalized, no leading slash; "" at root

	// One snapshot of the mount set serves the whole request,
	// so a concurrent config reload cannot pull a tree out from under us.
	mounts := *s.mounts.Load()

	// A request path inside a mount is served from that tree.
	for mount, tr := range mounts {
		if req == mount || strings.HasPrefix(req, mount+"/") {
			rel := strings.TrimPrefix(strings.TrimPrefix(req, mount), "/")
			if rel == "" {
				rel = "."
			}
			s.serveFromTree(w, r, tr, mount, rel)
			return
		}
	}

	// A request path *above* a mount gets a synthetic listing
	// showing only the exposed next-level names.
	if kids := mountChildren(mounts, req); kids != nil {
		if req != "" && !strings.HasSuffix(r.URL.Path, "/") {
			http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
			return
		}
		title := "vitrine"
		if req != "" {
			title = req + "/"
		}
		renderPage(w, pageData{Title: title, Crumbs: crumbs(req), Listing: kids})
		return
	}

	http.NotFound(w, r)
}

// mountChildren returns the listing for a synthetic (above-mount) directory:
// the deduplicated next path segments of all mounts under the given prefix.
// Returns nil if no mount lives under this prefix.
func mountChildren(mounts mountSet, prefix string) []dirEntry {
	seen := make(map[string]bool)
	for mount := range mounts {
		rest := mount
		if prefix != "" {
			if !strings.HasPrefix(mount, prefix+"/") {
				continue
			}
			rest = strings.TrimPrefix(mount, prefix+"/")
		}
		seg, _, _ := strings.Cut(rest, "/")
		seen[seg] = true
	}
	if len(seen) == 0 {
		return nil
	}
	names := make([]string, 0, len(seen))
	for seg := range seen {
		names = append(names, seg)
	}
	sort.Strings(names)
	entries := make([]dirEntry, 0, len(names)+1)
	if prefix != "" {
		entries = append(entries, dirEntry{Name: "../", Href: "../"})
	}
	for _, seg := range names {
		entries = append(entries, dirEntry{Name: seg + "/", Href: seg + "/"})
	}
	return entries
}

// hasGitSegment reports whether any segment of a slash-separated relative path is ".git".
// Listings hide .git at every depth (nested repos included), so URL reachability matches.
func hasGitSegment(rel string) bool {
	for _, seg := range strings.Split(rel, "/") {
		if seg == ".git" {
			return true
		}
	}
	return false
}

// serveFromTree dispatches a request that resolved inside a mounted tree.
// Unless the exhibit opts in via expose-git, any path with a .git segment is rejected here,
// which covers direct URLs as well as listings (listings are filtered in serveDir).
func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, tr tree, mount, rel string) {
	if !tr.exposeGit && hasGitSegment(rel) {
		http.NotFound(w, r)
		return
	}
	root := tr.root
	info, err := root.Stat(rel)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	switch {
	case info.IsDir():
		if !strings.HasSuffix(r.URL.Path, "/") {
			http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
			return
		}
		s.serveDir(w, r, tr, mount, rel)
	case r.URL.Query().Get("fmt") == "raw":
		serveRaw(w, r, root, rel)
	case hasGitSegment(rel):
		// Files inside an exposed .git serve raw always: git's dumb-HTTP
		// client fetches info/refs, HEAD, objects, etc. as plain GETs with
		// no query string, and chokes on the HTML-wrapped rendered view.
		// (Reaching here at all means the exhibit opted in via expose-git;
		// the non-exposed case was rejected at the top of serveFromTree.)
		// Rendering .git contents as pages was never meaningful anyway.
		serveRaw(w, r, root, rel)
	default:
		serveRendered(w, r, root, mount, rel, info.Size())
	}
}

// serveRaw sends the file bytes.
// Markdown gets text/plain so browsers display it instead of downloading;
// everything else gets stdlib content-type detection via ServeContent.
func serveRaw(w http.ResponseWriter, r *http.Request, root *os.Root, rel string) {
	f, err := root.Open(rel)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	defer f.Close()
	info, err := f.Stat()
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	if strings.HasSuffix(rel, ".md") {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	}
	http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}

// serveDir renders a directory listing.
// The .git directory is omitted unless the exhibit opts in via expose-git:
// it's not useful to browse and clutters the view.
func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount, rel string) {
	ents, err := fs.ReadDir(tr.root.FS(), rel)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var listing []dirEntry
	listing = append(listing, dirEntry{Name: "../", Href: "../"})
	for _, e := range ents {
		if e.Name() == ".git" && !tr.exposeGit {
			continue
		}
		n := e.Name()
		if e.IsDir() {
			n += "/"
		}
		listing = append(listing, dirEntry{Name: n, Href: n})
	}
	renderPage(w, pageData{
		Title:   mount + "/" + strings.TrimSuffix(rel, "."),
		Crumbs:  crumbs(joinURL(mount, rel)),
		Listing: listing,
	})
}
