// Copyright 2023 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 content implements a basic web serving framework.
//
// # Content Server
//
// A content server is an http.Handler that serves requests from a file system.
// Use Server(fsys) to create a new content server.
//
// The server is defined primarily by the content of its file system fsys,
// which holds files to be served. It renders markdown files and golang
// templates into HTML.
//
// # Page Rendering
//
// A request for a path like "/page" will search the file system for
// "page.md", "page.html", "page/index.md", and "page/index.html" and
// render HTML output for the first file found.
//
// Partial templates with the extension ".tmpl" at the root of the file system
// and in the same directory as the requested page are included in the
// html/template execution step to allow for sharing and composing logic from
// multiple templates.
//
// Markdown templates must have an html layout template set in the frontmatter
// section. The markdown content is available to the layout template as the
// field `{{.Content}}`.
package content

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io/fs"
	"log/slog"
	"net/http"
	"path"
	"strconv"
	"strings"

	"github.com/yuin/goldmark"
	meta "github.com/yuin/goldmark-meta"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer/html"
)

// contentServer serves requests for a given file system and renders html
// templates.
type contentServer struct {
	fsys     fs.FS
	fserv    http.Handler
	handlers map[string]HandlerFunc
}

type HandlerFunc func(http.ResponseWriter, *http.Request) error

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if err := f(w, r); err != nil {
		handleErr(w, r, err, http.StatusInternalServerError)
	}
}

// Server returns a handler that serves HTTP requests with the contents
// of the file system rooted at fsys. For requests to a path without an
// extension, the server will search fsys for markdown or html templates
// first by appending .md, .html, /index.md, and /index.html to the
// requested url path.
//
// The default behavior of looking for templates within fsys can be overridden
// by using an optional set of content handlers.
//
// For example, a server can be constructed for a file system with a single
// template, “index.html“, in a directory, “content“, and a handler:
//
//	  fsys := os.DirFS("content")
//		 s := content.Server(fsys,
//			 content.Handler("/", func(w http.ReponseWriter, _ *http.Request) error {
//			 	 return content.Template(w, fsys, "index.html", nil, http.StatusOK)
//			 }))
//
// or without a handler:
//
//	content.Server(os.DirFS("content"))
//
// Both examples will render the template index.html for requests to "/".
func Server(fsys fs.FS, handlers ...*handler) http.Handler {
	fserv := http.FileServer(http.FS(fsys))
	hs := make(map[string]HandlerFunc)
	for _, h := range handlers {
		if _, ok := hs[h.path]; ok {
			panic("multiple registrations for " + h.path)
		}
		hs[h.path] = h.fn
	}
	return &contentServer{fsys, fserv, hs}
}

type handler struct {
	path string
	fn   HandlerFunc
}

func Handler(path string, h HandlerFunc) *handler {
	return &handler{path, h}
}

func (c *contentServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if len(r.URL.Path) > 255 {
		handleErr(w, r, errors.New("url too long"), http.StatusBadRequest)
		return
	}

	if h, ok := c.handlers[r.URL.Path]; ok {
		h.ServeHTTP(w, r)
		return
	}

	ext := path.Ext(r.URL.Path)
	if ext == ".md" || ext == ".html" {
		http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, ext), http.StatusMovedPermanently)
		return
	}

	filepath, err := stat(c.fsys, r.URL.Path)
	if errors.Is(err, fs.ErrNotExist) {
		handleErr(w, r, errors.New(http.StatusText(http.StatusNotFound)), http.StatusNotFound)
		return
	}
	if err == nil {
		if strings.HasSuffix(r.URL.Path, "/index") {
			http.Redirect(w, r, strings.TrimSuffix(r.URL.Path, "/index"), http.StatusMovedPermanently)
			return
		}
		switch path.Ext(filepath) {
		case ".html":
			err = Template(w, c.fsys, filepath, nil, http.StatusOK)
		case ".md":
			err = markdown(w, c.fsys, filepath, http.StatusOK)
		default:
			c.fserv.ServeHTTP(w, r)
		}
	}
	if err != nil {
		handleErr(w, r, err, http.StatusInternalServerError)
	}
}

// Template executes a template response.
// TODO(rfindley): this abstraction no longer holds its weight. Refactor.
func Template(w http.ResponseWriter, fsys fs.FS, tmplPath string, data any, code int) error {
	patterns, err := tmplPatterns(fsys, tmplPath)
	if err != nil {
		return err
	}
	patterns = append(patterns, tmplPath)
	tmpl, err := template.New("").Funcs(chartFuncs()).ParseFS(fsys, patterns...)
	if err != nil {
		return err
	}
	name := path.Base(tmplPath)
	var buf bytes.Buffer
	if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
		return err
	}
	if code != 0 {
		w.WriteHeader(code)
	}
	w.Header().Set("Content-Type", "text/html")
	w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
	if _, err := w.Write(buf.Bytes()); err != nil {
		return err
	}
	return nil
}

// TODO(rfindley): refactor so that these funcs are only required by templates
// that use them.
func chartFuncs() template.FuncMap {
	return template.FuncMap{
		"chartName": func(name string) string {
			name, _, _ = strings.Cut(name, ":")
			return name
		},
		"programName": func(name string) string {
			name = strings.TrimPrefix(name, "golang.org/")
			name = strings.TrimPrefix(name, "github.com/")
			return name
		},
	}
}

// JSON encodes data as JSON response with a status code.
func JSON(w http.ResponseWriter, data any, code int) error {
	var buf bytes.Buffer
	if err := json.NewEncoder(&buf).Encode(data); err != nil {
		return err
	}
	if code != 0 {
		w.WriteHeader(code)
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
	if _, err := w.Write(buf.Bytes()); err != nil {
		return err
	}
	return nil
}

// Text formats data as a text response with a status code.
func Text(w http.ResponseWriter, data any, code int) error {
	var buf bytes.Buffer
	if _, err := fmt.Fprint(&buf, data); err != nil {
		return err
	}
	if code != 0 {
		w.WriteHeader(code)
	}
	w.Header().Set("Content-Type", "text/plain")
	w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
	if _, err := w.Write(buf.Bytes()); err != nil {
		return err
	}
	return nil
}

// TODO(rfindley): this docstring is stale, and Status should be a pure
// function.
// Text renders an http status code as a text response.
func Status(w http.ResponseWriter, code int) error {
	if code < http.StatusBadRequest {
		return Text(w, http.StatusText(code), code)
	}
	return Error(errors.New(http.StatusText(code)), code)
}

// Error annotates an error with http status information.
func Error(err error, code int) error {
	return &contentError{err, code}
}

type contentError struct {
	err  error
	Code int
}

func (e *contentError) Error() string { return e.err.Error() }

// handleErr writes an error as an HTTP response with a status code.
//
// err must be non-nil when calling this function.
func handleErr(w http.ResponseWriter, req *http.Request, err error, code int) {
	if cerr, ok := err.(*contentError); ok {
		code = cerr.Code
	}
	// Log the error, but only the first 80 characters.
	// This prevents excessive logging related to broken payloads.
	// The first line should give us a sense of the failure mode.
	errs := []rune(err.Error())
	if len(errs) > 80 {
		errs = append(errs[:79], '…')
	}
	slog.WarnContext(req.Context(), fmt.Sprintf("request for %q failed with status %d: %s", req.URL.Path, code, string(errs)))
	if code == http.StatusInternalServerError {
		http.Error(w, http.StatusText(http.StatusInternalServerError), code)
	} else {
		http.Error(w, err.Error(), code)
	}
}

// markdown renders a markdown template as html.
func markdown(w http.ResponseWriter, fsys fs.FS, tmplPath string, code int) error {
	markdown, err := fs.ReadFile(fsys, tmplPath)
	if err != nil {
		return err
	}
	md := goldmark.New(
		goldmark.WithParserOptions(
			parser.WithHeadingAttribute(),
			parser.WithAutoHeadingID(),
		),
		goldmark.WithRendererOptions(
			html.WithUnsafe(),
			html.WithXHTML(),
		),
		goldmark.WithExtensions(
			extension.GFM,
			extension.NewTypographer(),
			meta.Meta,
		),
	)
	var content bytes.Buffer
	ctx := parser.NewContext()
	if err := md.Convert(markdown, &content, parser.WithContext(ctx)); err != nil {
		return err
	}
	data := meta.Get(ctx)
	if data == nil {
		data = map[string]interface{}{}
	}
	data["Content"] = template.HTML(content.String())
	layout, ok := data["Layout"]
	if !ok {
		return errors.New("missing layout for template " + tmplPath)
	}
	return Template(w, fsys, layout.(string), data, code)
}

// stat trys to coerce a urlPath into an openable file then returns the
// file path.
func stat(fsys fs.FS, urlPath string) (string, error) {
	cleanPath := path.Clean(strings.TrimPrefix(urlPath, "/"))
	ext := path.Ext(cleanPath)
	filePaths := []string{cleanPath}
	if ext == "" || ext == "." {
		md := cleanPath + ".md"
		html := cleanPath + ".html"
		indexMD := path.Join(cleanPath, "index.md")
		indexHTML := path.Join(cleanPath, "index.html")
		filePaths = []string{md, html, indexMD, indexHTML, cleanPath}
	}
	var p string
	var err error
	for _, p = range filePaths {
		if _, err = fs.Stat(fsys, p); err == nil {
			break
		}
	}
	return p, err
}

// tmplPatterns generates a slice of file patterns to use in template.ParseFS.
func tmplPatterns(fsys fs.FS, tmplPath string) ([]string, error) {
	var patterns []string
	globs := []string{"*.tmpl", path.Join(path.Dir(tmplPath), "*.tmpl")}
	for _, g := range globs {
		matches, err := fs.Glob(fsys, g)
		if err != nil {
			return nil, err
		}
		patterns = append(patterns, matches...)
	}
	return patterns, nil
}
