// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package vcweb

import (
	"io"
	"log"
	"net"
	"net/http"
	"os/exec"
	"strings"
	"sync"
)

// An svnHandler serves requests for Subversion repos.
//
// Unlike the other vcweb handlers, svnHandler does not serve the Subversion
// protocol directly over the HTTP connection. Instead, it opens a separate port
// that serves the (non-HTTP) 'svn' protocol. The test binary can retrieve the
// URL for that port by sending an HTTP request with the query parameter
// "vcwebsvn=1".
//
// We take this approach because the 'svn' protocol is implemented by a
// lightweight 'svnserve' binary that is usually packaged along with the 'svn'
// client binary, whereas only known implementation of the Subversion HTTP
// protocol is the mod_dav_svn apache2 module. Apache2 has a lot of dependencies
// and also seems to rely on global configuration via well-known file paths, so
// implementing a hermetic test using apache2 would require the test to run in a
// complicated container environment, which wouldn't be nearly as
// straightforward for Go contributors to set up and test against on their local
// machine.
type svnHandler struct {
	svnRoot string // a directory containing all svn repos to be served
	logger  *log.Logger

	pathOnce     sync.Once
	svnservePath string // the path to the 'svnserve' executable
	svnserveErr  error

	listenOnce sync.Once
	s          chan *svnState // 1-buffered
}

// An svnState describes the state of a port serving the 'svn://' protocol.
type svnState struct {
	listener  net.Listener
	listenErr error
	conns     map[net.Conn]struct{}
	closing   bool
	done      chan struct{}
}

func (h *svnHandler) Available() bool {
	h.pathOnce.Do(func() {
		h.svnservePath, h.svnserveErr = exec.LookPath("svnserve")
	})
	return h.svnserveErr == nil
}

// Handler returns an http.Handler that checks for the "vcwebsvn" query
// parameter and then serves the 'svn://' URL for the repository at the
// requested path.
// The HTTP client is expected to read that URL and pass it to the 'svn' client.
func (h *svnHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
	if !h.Available() {
		return nil, ServerNotInstalledError{name: "svn"}
	}

	// Go ahead and start the listener now, so that if it fails (for example, due
	// to port exhaustion) we can return an error from the Handler method instead
	// of serving an error for each individual HTTP request.
	h.listenOnce.Do(func() {
		h.s = make(chan *svnState, 1)
		l, err := net.Listen("tcp", "localhost:0")
		done := make(chan struct{})

		h.s <- &svnState{
			listener:  l,
			listenErr: err,
			conns:     map[net.Conn]struct{}{},
			done:      done,
		}
		if err != nil {
			close(done)
			return
		}

		h.logger.Printf("serving svn on svn://%v", l.Addr())

		go func() {
			for {
				c, err := l.Accept()

				s := <-h.s
				if err != nil {
					s.listenErr = err
					if len(s.conns) == 0 {
						close(s.done)
					}
					h.s <- s
					return
				}
				if s.closing {
					c.Close()
				} else {
					s.conns[c] = struct{}{}
					go h.serve(c)
				}
				h.s <- s
			}
		}()
	})

	s := <-h.s
	addr := ""
	if s.listener != nil {
		addr = s.listener.Addr().String()
	}
	err := s.listenErr
	h.s <- s
	if err != nil {
		return nil, err
	}

	handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		if req.FormValue("vcwebsvn") != "" {
			w.Header().Add("Content-Type", "text/plain; charset=UTF-8")
			io.WriteString(w, "svn://"+addr+"\n")
			return
		}
		http.NotFound(w, req)
	})

	return handler, nil
}

// serve serves a single 'svn://' connection on c.
func (h *svnHandler) serve(c net.Conn) {
	defer func() {
		c.Close()

		s := <-h.s
		delete(s.conns, c)
		if len(s.conns) == 0 && s.listenErr != nil {
			close(s.done)
		}
		h.s <- s
	}()

	// The "--inetd" flag causes svnserve to speak the 'svn' protocol over its
	// stdin and stdout streams as if invoked by the Unix "inetd" service.
	// We aren't using inetd, but we are implementing essentially the same
	// approach: using a host process to listen for connections and spawn
	// subprocesses to serve them.
	cmd := exec.Command(h.svnservePath, "--read-only", "--root="+h.svnRoot, "--inetd")
	cmd.Stdin = c
	cmd.Stdout = c
	stderr := new(strings.Builder)
	cmd.Stderr = stderr
	err := cmd.Run()

	var errFrag any = "ok"
	if err != nil {
		errFrag = err
	}
	stderrFrag := ""
	if stderr.Len() > 0 {
		stderrFrag = "\n" + stderr.String()
	}
	h.logger.Printf("%v: %s%s", cmd, errFrag, stderrFrag)
}

// Close stops accepting new svn:// connections and terminates the existing
// ones, then waits for the 'svnserve' subprocesses to complete.
func (h *svnHandler) Close() error {
	h.listenOnce.Do(func() {})
	if h.s == nil {
		return nil
	}

	var err error
	s := <-h.s
	s.closing = true
	if s.listener == nil {
		err = s.listenErr
	} else {
		err = s.listener.Close()
	}
	for c := range s.conns {
		c.Close()
	}
	done := s.done
	h.s <- s

	<-done
	return err
}
