// Package statigz serves pre-compressed embedded files with http.
package statigz

import (
	"bytes"
	"compress/gzip"
	"hash/fnv"
	"io"
	"io/fs"
	"mime"
	"net/http"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

// Server is a http.Handler that directly serves compressed files from file system to capable agents.
//
// Please use FileServer to create an instance of Server.
//
// If agent does not accept encoding and uncompressed file is not available in file system,
// it would decompress the file before serving.
//
// Compressed files should have an additional extension to indicate their encoding,
// for example "style.css.gz" or "bundle.js.br".
//
// Caching is implemented with ETag and If-None-Match headers. Range requests are supported
// with help of http.ServeContent.
//
// Behavior is similar to http://nginx.org/en/docs/http/ngx_http_gzip_static_module.html and
// https://github.com/lpar/gzipped, except compressed data can be decompressed for an incapable agent.
type Server struct {
	// OnError controls error handling during Serve.
	OnError func(rw http.ResponseWriter, r *http.Request, err error)

	// Encodings contains supported encodings, default GzipEncoding.
	Encodings []Encoding

	// EncodeOnInit encodes files that does not have encoded version on Server init.
	// This allows embedding uncompressed files and still leverage one time compression
	// for multiple requests.
	// Enabling this option can degrade startup performance and memory usage in case
	// of large embeddings, use with caution.
	EncodeOnInit bool

	info map[string]fileInfo
	fs   fs.ReadDirFS
}

const (
	// minSizeToEncode is minimal file size to apply encoding in runtime, 1KiB.
	minSizeToEncode = 1024

	// minCompressionRatio is a minimal compression ratio to serve encoded data, 97%.
	minCompressionRatio = 0.97
)

// SkipCompressionExt lists file extensions of data that is already compressed.
var SkipCompressionExt = []string{".gz", ".br", ".gif", ".jpg", ".png", ".webp"}

// FileServer creates an instance of Server from file system.
//
// Typically file system would be an embed.FS.
//
//   //go:embed *.png *.br
//	 var FS embed.FS
//
// Brotli support is optionally available with brotli.AddEncoding.
func FileServer(fs fs.ReadDirFS, options ...func(server *Server)) *Server {
	s := Server{
		fs:   fs,
		info: make(map[string]fileInfo),
		OnError: func(rw http.ResponseWriter, r *http.Request, err error) {
			http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
		},
		Encodings: []Encoding{GzipEncoding()},
	}

	for _, o := range options {
		o(&s)
	}

	// Reading from "." is not expected to fail.
	if err := s.hashDir("."); err != nil {
		panic(err)
	}

	if s.EncodeOnInit {
		err := s.encodeFiles()
		if err != nil {
			panic(err)
		}
	}

	return &s
}

func (s *Server) encodeFiles() error {
	for _, enc := range s.Encodings {
		if enc.Encoder == nil {
			continue
		}

		for fn, i := range s.info {
			isEncoded := false

			for _, ext := range SkipCompressionExt {
				if strings.HasSuffix(fn, ext) {
					isEncoded = true

					break
				}
			}

			if isEncoded {
				continue
			}

			if _, found := s.info[fn+enc.FileExt]; found {
				continue
			}

			// Skip encoding of small data.
			if i.size < minSizeToEncode {
				continue
			}

			f, err := s.fs.Open(fn)
			if err != nil {
				return err
			}

			b, err := enc.Encoder(f)
			if err != nil {
				return err
			}

			// Skip encoding for non-compressible data.
			if float64(len(b))/float64(i.size) > minCompressionRatio {
				continue
			}

			s.info[fn+enc.FileExt] = fileInfo{
				hash:    i.hash + enc.FileExt,
				size:    len(b),
				content: b[0:len(b):len(b)],
			}
		}
	}

	return nil
}

func (s *Server) hashDir(p string) error {
	files, err := s.fs.ReadDir(p)
	if err != nil {
		return err
	}

	for _, f := range files {
		fn := path.Join(p, f.Name())

		if f.IsDir() {
			s.info[path.Clean(fn)] = fileInfo{
				isDir: true,
			}

			if err = s.hashDir(fn); err != nil {
				return err
			}

			continue
		}

		h := fnv.New64()

		f, err := s.fs.Open(fn)
		if err != nil {
			return err
		}

		n, err := io.Copy(h, f)
		if err != nil {
			return err
		}

		s.info[path.Clean(fn)] = fileInfo{
			hash: strconv.FormatUint(h.Sum64(), 36),
			size: int(n),
		}
	}

	return nil
}

func (s *Server) reader(fn string, info fileInfo) (io.Reader, error) {
	if info.content != nil {
		return bytes.NewReader(info.content), nil
	}

	return s.fs.Open(fn)
}

func (s *Server) serve(rw http.ResponseWriter, req *http.Request, fn, suf, enc string, info fileInfo,
	decompress func(r io.Reader) (io.Reader, error)) {
	if m := req.Header.Get("If-None-Match"); m == info.hash {
		rw.WriteHeader(http.StatusNotModified)

		return
	}

	ctype := mime.TypeByExtension(filepath.Ext(fn))
	if ctype == "" {
		ctype = "application/octet-stream" // Prevent unreliable Content-Type detection on compressed data.
	}

	// This is used to enforce application/javascript MIME on Windows (https://github.com/golang/go/issues/32350)
	if strings.HasSuffix(req.URL.Path, ".js") {
		ctype = "application/javascript"
	}

	rw.Header().Set("Content-Type", ctype)
	rw.Header().Set("Etag", info.hash)

	if enc != "" {
		rw.Header().Set("Content-Encoding", enc)
	}

	if info.size > 0 {
		rw.Header().Set("Content-Length", strconv.Itoa(info.size))
	}

	if req.Method == http.MethodHead {
		return
	}

	r, err := s.reader(fn+suf, info)
	if err != nil {
		s.OnError(rw, req, err)

		return
	}

	if decompress != nil {
		r, err = decompress(r)
		if err != nil {
			rw.Header().Del("Etag")
			s.OnError(rw, req, err)

			return
		}
	}

	if rs, ok := r.(io.ReadSeeker); ok {
		http.ServeContent(rw, req, fn, time.Time{}, rs)

		return
	}

	_, err = io.Copy(rw, r)
	if err != nil {
		s.OnError(rw, req, err)

		return
	}
}

func (s *Server) minEnc(accessEncoding string, fn string) (fileInfo, Encoding) {
	var (
		minEnc  Encoding
		minInfo = fileInfo{size: -1}
	)

	for _, enc := range s.Encodings {
		if !strings.Contains(accessEncoding, enc.ContentEncoding) {
			continue
		}

		info, found := s.info[fn+enc.FileExt]
		if !found {
			continue
		}

		if minInfo.size == -1 || info.size < minInfo.size {
			minEnc = enc
			minInfo = info
		}
	}

	return minInfo, minEnc
}

// ServeHTTP serves static files.
//
// For compatibility with std http.FileServer:
// if request path ends with /index.html, it is redirected to base directory;
// if request path points to a directory without trailing "/", it is redirected to a path with trailing "/".
func (s *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
	if req.Method != http.MethodGet && req.Method != http.MethodHead {
		rw.Header().Set("Allow", http.MethodGet+", "+http.MethodHead)
		http.Error(rw, "Method Not Allowed\n\nmethod should be GET or HEAD", http.StatusMethodNotAllowed)

		return
	}

	if strings.HasSuffix(req.URL.Path, "/index.html") {
		localRedirect(rw, req, "./")

		return
	}

	fn := strings.TrimPrefix(req.URL.Path, "/")
	ae := req.Header.Get("Accept-Encoding")

	if s.info[fn].isDir {
		localRedirect(rw, req, path.Base(req.URL.Path)+"/")

		return
	}

	if fn == "" || strings.HasSuffix(fn, "/") {
		fn += "index.html"
	}

	// Always add Accept-Encoding to Vary to prevent intermediate caches corruption.
	rw.Header().Add("Vary", "Accept-Encoding")

	if ae != "" {
		minInfo, minEnc := s.minEnc(strings.ToLower(ae), fn)

		if minInfo.hash != "" {
			// Copy compressed data into response.
			s.serve(rw, req, fn, minEnc.FileExt, minEnc.ContentEncoding, minInfo, nil)

			return
		}
	}

	// Copy uncompressed data into response.
	uncompressedInfo, uncompressedFound := s.info[fn]
	if uncompressedFound {
		s.serve(rw, req, fn, "", "", uncompressedInfo, nil)

		return
	}

	// Decompress compressed data into response.
	for _, enc := range s.Encodings {
		info, found := s.info[fn+enc.FileExt]
		if !found || enc.Decoder == nil || info.isDir {
			continue
		}

		info.hash += "U"
		info.size = 0
		s.serve(rw, req, fn, enc.FileExt, "", info, enc.Decoder)

		return
	}

	http.NotFound(rw, req)
}

// Encoding describes content encoding.
type Encoding struct {
	// FileExt is an extension of file with compressed content, for example ".gz".
	FileExt string

	// ContentEncoding is encoding name that is used in Accept-Encoding and Content-Encoding
	// headers, for example "gzip".
	ContentEncoding string

	// Decoder is a function that can decode data for an agent that does not accept encoding,
	// can be nil to disable dynamic decompression.
	Decoder func(r io.Reader) (io.Reader, error)

	// Encoder is a function that can encode data
	Encoder func(r io.Reader) ([]byte, error)
}

type fileInfo struct {
	hash    string
	size    int
	content []byte
	isDir   bool
}

// OnError is an option to customize error handling in Server.
func OnError(onErr func(rw http.ResponseWriter, r *http.Request, err error)) func(server *Server) {
	return func(server *Server) {
		server.OnError = onErr
	}
}

// GzipEncoding provides gzip Encoding.
func GzipEncoding() Encoding {
	return Encoding{
		FileExt:         ".gz",
		ContentEncoding: "gzip",
		Decoder: func(r io.Reader) (io.Reader, error) {
			return gzip.NewReader(r)
		},
		Encoder: func(r io.Reader) ([]byte, error) {
			res := bytes.NewBuffer(nil)
			w := gzip.NewWriter(res)

			if _, err := io.Copy(w, r); err != nil {
				return nil, err
			}

			if err := w.Close(); err != nil {
				return nil, err
			}

			return res.Bytes(), nil
		},
	}
}

// EncodeOnInit enables runtime encoding for unencoded files to allow compression
// for uncompressed embedded files.
//
// Enabling this option can degrade startup performance and memory usage in case
// of large embeddings, use with caution.
func EncodeOnInit(server *Server) {
	server.EncodeOnInit = true
}

// localRedirect gives a Moved Permanently response.
// It does not convert relative paths to absolute paths like Redirect does.
//
// Copied go1.17/src/net/http/fs.go:685.
func localRedirect(w http.ResponseWriter, r *http.Request, newPath string) {
	if q := r.URL.RawQuery; q != "" {
		newPath += "?" + q
	}

	w.Header().Set("Location", newPath)
	w.WriteHeader(http.StatusMovedPermanently)
}
