// SPDX-FileCopyrightText: 2021 Luke Granger-Brown <depot@lukegb.com>
//
// SPDX-License-Identifier: Apache-2.0

package fuphttp

import (
	"context"
	"fmt"
	"io/fs"
	"log"
	"net/http"
	"strings"
	"time"

	"github.com/google/safehtml"
	"github.com/google/safehtml/template"
	"github.com/google/safehtml/template/uncheckedconversions"
	shuncheckedconversions "github.com/google/safehtml/uncheckedconversions"
	"github.com/gorilla/mux"
	"gocloud.dev/blob"
	"hg.lukegb.com/lukegb/depot/web/fup/fuphttp/fngen"
	"hg.lukegb.com/lukegb/depot/web/fup/hashfs"
)

const (
	defaultRedirectExpiry = 5 * time.Minute
)

type Highlighter interface {
	Markdown(ctx context.Context, text string) (safehtml.HTML, error)
	Code(ctx context.Context, filename, theme, text string) (safehtml.HTML, error)
}

type Config struct {
	Templates  fs.FS
	Static     fs.FS
	StaticRoot safehtml.TrustedResourceURL

	AppRoot string

	// If set, redirects to a signed URL if possible instead of serving directly.
	RedirectToBlobstore bool

	// RedirectExpiry sets the maximum lifetime of a signed URL, if RedirectToBlobstore is in use and the backend supports signed URLs.
	// Note that if a file has an expiry then the signed URL's validity will be capped at the expiry of the underlying file.
	RedirectExpiry time.Duration // Defaults to 5 minutes.

	// Set one of these, but not both.
	StorageURL     string
	StorageBackend *blob.Bucket

	// FilenameGenerator returns a new filename based on the provided prefix and extension.
	FilenameGenerator fngen.FilenameGenerator

	// Highlighter is used for syntax highlighting and Markdown rendering.
	// If nil, then no syntax highlighting or Markdown rendering will be performed.
	Highlighter Highlighter

	// UseDirectDownload decides whether the "pretty" wrapped page or the direct download page is the most appropriate for a given set of parameters.
	UseDirectDownload func(fileExtension string, mimeType string) bool

	// AuthMiddleware is a Gorilla middleware to provide authentication information.
	//
	// It runs on every handler, including public ones, and will be provided with information:
	// - if the page is an upload page (either the homepage, the paste textbox page, or the upload handler), the IsMutate will return true.
	// - if the page is an API handler, then IsAPIRequest will return true.
	AuthMiddleware mux.MiddlewareFunc
}

type Application struct {
	indexTmpl        *template.Template
	pasteTmpl        *template.Template
	notFoundTmpl     *template.Template
	viewTextTmpl     *template.Template
	viewRenderedTmpl *template.Template
	viewBinaryTmpl   *template.Template

	storageBackend *blob.Bucket

	appRoot string

	highlighter Highlighter

	redirectToBlobstore bool
	redirectExpiry      time.Duration

	filenameGenerator fngen.FilenameGenerator
	useDirectDownload func(fileExtension string, mimeType string) bool

	authMiddleware mux.MiddlewareFunc
}

func DefaultUseDirectDownload(fileExtension, mimeType string) bool {
	switch mimeType {
	case "application/json", "application/xml", "application/xhtml+xml", "application/x-csh", "application/x-sh":
		return false
	}
	return !strings.HasPrefix(mimeType, "text/")
}

func isAPIRequest(r *http.Request) bool {
	return r.Header.Get("Accept") == "application/json"
}

type contextKey string

const (
	ctxKeyIsMutate     = contextKey("isMutate")
	ctxKeyIsAPIRequest = contextKey("isAPIRequest")
)

// IsAPIRequest determines whether a request was made from an API client rather than from a browser.
func IsAPIRequest(ctx context.Context) bool {
	return ctx.Value(ctxKeyIsAPIRequest).(bool)
}

// IsMutate determines whether a request for the given context is for a "mutate".
// This includes things like the HTML for the home page, which in itself is not a mutate but is useless if you're not allowed to auth.
func IsMutate(ctx context.Context) bool {
	return ctx.Value(ctxKeyIsMutate).(bool)
}

func contextPopulateMiddleware(isMutate bool) mux.MiddlewareFunc {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
			ctx := r.Context()
			ctx = context.WithValue(ctx, ctxKeyIsMutate, isMutate)
			ctx = context.WithValue(ctx, ctxKeyIsAPIRequest, isAPIRequest(r))
			next.ServeHTTP(rw, r.WithContext(ctx))
		})
	}
}

func (a *Application) Handler() http.Handler {
	r := mux.NewRouter()

	renderTemplate := func(t *template.Template) http.HandlerFunc {
		return func(rw http.ResponseWriter, r *http.Request) {
			if err := t.Execute(rw, nil); err != nil {
				log.Printf("rendering template: %v", err)
			}
		}
	}

	r.NotFoundHandler = http.HandlerFunc(a.notFound)

	authR := r.PathPrefix("/").Subrouter()
	authR.HandleFunc("/", renderTemplate(a.indexTmpl))
	authR.HandleFunc("/paste", renderTemplate(a.pasteTmpl))
	authR.HandleFunc("/upload", a.upload).Methods("POST", "PUT")
	authR.HandleFunc("/upload/{filename}", a.upload).Methods("PUT")

	publicR := r.PathPrefix("/").Subrouter()
	publicR.HandleFunc("/raw/{filename}", a.rawDownload)
	publicR.HandleFunc("/{filename}", a.view)

	if a.authMiddleware != nil {
		authR.Use(contextPopulateMiddleware(true))
		authR.Use(a.authMiddleware)
		publicR.Use(contextPopulateMiddleware(false))
		publicR.Use(a.authMiddleware)
	}

	return r
}

func (a *Application) notFound(rw http.ResponseWriter, r *http.Request) {
	rw.WriteHeader(http.StatusNotFound)
	if err := a.notFoundTmpl.Execute(rw, nil); err != nil {
		log.Printf("rendering 404 template: %v", err)
	}
}

func (a *Application) internalError(rw http.ResponseWriter, r *http.Request) {
	rw.Header().Set("Content-type", "text/plain; charset=utf-8")
	rw.Header().Set("X-Content-Type-Options", "nosniff")
	rw.WriteHeader(http.StatusInternalServerError)
	fmt.Fprintf(rw, "hammed server :(\n")
}

func (a *Application) badRequest(rw http.ResponseWriter, r *http.Request, err error) {
	rw.Header().Set("Content-type", "text/plain; charset=utf-8")
	rw.Header().Set("X-Content-Type-Options", "nosniff")
	rw.WriteHeader(http.StatusBadRequest)
	fmt.Fprintf(rw, "bad request: %v\n", err.Error())
}

func (a *Application) appURL(s string) string {
	return a.appRoot + s
}

func parseTemplate(t *template.Template, fsys fs.FS, name string) (*template.Template, error) {
	bs, err := fs.ReadFile(fsys, name)
	if err != nil {
		return nil, fmt.Errorf("reading template %q: %w", name, err)
	}

	return t.ParseFromTrustedTemplate(
		uncheckedconversions.TrustedTemplateFromStringKnownToSatisfyTypeContract(string(bs)),
	)
}

func loadTemplate(fsys fs.FS, name string, funcs template.FuncMap) (*template.Template, error) {
	t := template.New(name).Funcs(funcs)
	var err error
	if t, err = parseTemplate(t, fsys, "base.html"); err != nil {
		return nil, fmt.Errorf("loading base template: %w", err)
	}
	if t, err = parseTemplate(t, fsys, fmt.Sprintf("%s.html", name)); err != nil {
		return nil, fmt.Errorf("loading leaf template: %w", err)
	}
	return t, nil
}

func New(ctx context.Context, cfg *Config) (*Application, error) {
	a := &Application{
		redirectToBlobstore: cfg.RedirectToBlobstore,
		redirectExpiry:      cfg.RedirectExpiry,
		filenameGenerator:   cfg.FilenameGenerator,
		useDirectDownload:   cfg.UseDirectDownload,
		appRoot:             cfg.AppRoot,
		highlighter:         cfg.Highlighter,
		authMiddleware:      cfg.AuthMiddleware,
	}
	if a.redirectExpiry == 0 {
		a.redirectExpiry = defaultRedirectExpiry
	}
	if a.filenameGenerator == nil {
		a.filenameGenerator = fngen.PetnameGenerator
	}
	if a.useDirectDownload == nil {
		a.useDirectDownload = DefaultUseDirectDownload
	}

	bkt := cfg.StorageBackend
	if bkt == nil {
		var err error
		if bkt, err = blob.OpenBucket(ctx, cfg.StorageURL); err != nil {
			return nil, fmt.Errorf("opening bucket %q: %v", cfg.StorageURL, err)
		}
	}
	a.storageBackend = bkt

	tmpls := []struct {
		t    **template.Template
		name string
	}{
		{&a.indexTmpl, "index"},
		{&a.pasteTmpl, "paste"},
		{&a.notFoundTmpl, "404"},
		{&a.viewTextTmpl, "text"},
		{&a.viewRenderedTmpl, "rendered"},
		{&a.viewBinaryTmpl, "binary"},
	}

	funcMap := template.FuncMap{
		"app": func(s string) safehtml.URL {
			return safehtml.URLSanitized(a.appRoot + s)
		},
		"static": func(s string) safehtml.TrustedResourceURL {
			staticPath := s
			if fs, ok := cfg.Static.(*hashfs.FS); ok {
				sp, ok := fs.LookupHashedName(staticPath)
				if ok {
					staticPath = sp
				} else {
					log.Printf("warning: couldn't find static file %v", staticPath)
				}
			}
			return shuncheckedconversions.TrustedResourceURLFromStringKnownToSatisfyTypeContract(cfg.StaticRoot.String() + staticPath)
		},
	}

	for _, tmpl := range tmpls {
		t, err := loadTemplate(cfg.Templates, tmpl.name, funcMap)
		if err != nil {
			return nil, fmt.Errorf("loading template %q: %w", tmpl.name, err)
		}
		*tmpl.t = t
	}

	return a, nil
}