package appfs

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"crypto/md5"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"mime"
	"net/http"
	"os"
	"path"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/andybalholm/brotli"
	"github.com/cozy/cozy-stack/pkg/consts"
	web_utils "github.com/cozy/cozy-stack/pkg/utils"
	lru "github.com/hashicorp/golang-lru/v2"
	"github.com/labstack/echo/v4"
	"github.com/ncw/swift/v2"
	"github.com/spf13/afero"
)

// FileServer interface defines a way to access and serve the application's
// data files.
type FileServer interface {
	Open(slug, version, shasum, file string) (io.ReadCloser, error)
	FilesList(slug, version, shasum string) ([]string, error)
	ServeFileContent(w http.ResponseWriter, req *http.Request,
		slug, version, shasum, file string) error
	ServeCodeTarball(w http.ResponseWriter, req *http.Request,
		slug, version, shasum string) error
}

type swiftServer struct {
	c         *swift.Connection
	container string
	ctx       context.Context
}

type aferoServer struct {
	mkPath func(slug, version, shasum, file string) string
	fs     afero.Fs
}

type brotliReadCloser struct {
	br *brotli.Reader
	cl io.Closer
}

// brotli.Reader has no Close method. This little wrapper adds a method to
// close the underlying reader.
func newBrotliReadCloser(r io.ReadCloser) (io.ReadCloser, error) {
	br := brotli.NewReader(r)
	return brotliReadCloser{br: br, cl: r}, nil
}

func (r brotliReadCloser) Read(b []byte) (int, error) {
	return r.br.Read(b)
}

func (r brotliReadCloser) Close() error {
	return r.cl.Close()
}

type gzipReadCloser struct {
	gr *gzip.Reader
	cl io.Closer
}

// The Close method of gzip.Reader does not closes the underlying reader. This
// little wrapper does the closing.
func newGzipReadCloser(r io.ReadCloser) (io.ReadCloser, error) {
	gr, err := gzip.NewReader(r)
	if err != nil {
		return nil, err
	}
	return gzipReadCloser{gr: gr, cl: r}, nil
}

func (g gzipReadCloser) Read(b []byte) (int, error) {
	return g.gr.Read(b)
}

func (g gzipReadCloser) Close() error {
	err1 := g.gr.Close()
	err2 := g.cl.Close()
	if err1 != nil {
		return err1
	}
	if err2 != nil {
		return err2
	}
	return nil
}

type cacheEntry struct {
	content []byte
	headers swift.Headers
}

var cache *lru.Cache[string, cacheEntry]
var initCacheOnce sync.Once

// NewSwiftFileServer returns provides the apps.FileServer implementation
// using the swift backend as file server.
func NewSwiftFileServer(conn *swift.Connection, appsType consts.AppType) FileServer {
	initCacheOnce.Do(func() {
		c, err := lru.New[string, cacheEntry](1024)
		if err != nil {
			panic(err)
		}
		cache = c
	})
	return &swiftServer{
		c:         conn,
		container: containerName(appsType),
		ctx:       context.Background(),
	}
}

func (s *swiftServer) openWithCache(objName string) (io.ReadCloser, swift.Headers, error) {
	entry, ok := cache.Get(objName)
	if !ok {
		f, h, err := s.c.ObjectOpen(s.ctx, s.container, objName, false, nil)
		if err != nil {
			return f, h, err
		}
		entry.headers = h
		entry.content, err = io.ReadAll(f)
		if err != nil {
			return nil, h, err
		}
		cache.Add(objName, entry)
	}
	f := io.NopCloser(bytes.NewReader(entry.content))
	return f, entry.headers, nil
}

func (s *swiftServer) Open(slug, version, shasum, file string) (io.ReadCloser, error) {
	objName := s.makeObjectName(slug, version, shasum, file)
	f, h, err := s.openWithCache(objName)
	if err != nil {
		return nil, wrapSwiftErr(err)
	}
	o := h.ObjectMetadata()
	contentEncoding := o["content-encoding"]
	if contentEncoding == "br" {
		return newBrotliReadCloser(f)
	} else if contentEncoding == "gzip" {
		return newGzipReadCloser(f)
	}
	return f, nil
}

func (s *swiftServer) ServeFileContent(w http.ResponseWriter, req *http.Request, slug, version, shasum, file string) error {
	objName := s.makeObjectName(slug, version, shasum, file)
	f, h, err := s.openWithCache(objName)
	if err != nil {
		return wrapSwiftErr(err)
	}
	defer f.Close()

	if checkETag := req.Header.Get("Cache-Control") == ""; checkETag {
		etag := fmt.Sprintf(`"%s"`, h["Etag"][:10])
		if web_utils.CheckPreconditions(w, req, etag) {
			return nil
		}
		w.Header().Set("Etag", etag)
	}

	var r io.Reader = f
	contentLength := h["Content-Length"]
	contentType := h["Content-Type"]
	o := h.ObjectMetadata()
	contentEncoding := o["content-encoding"]
	if contentEncoding == "br" {
		if acceptBrotliEncoding(req) {
			w.Header().Set(echo.HeaderContentEncoding, "br")
		} else {
			contentLength = o["original-content-length"]
			r = brotli.NewReader(f)
		}
	} else if contentEncoding == "gzip" {
		if acceptGzipEncoding(req) {
			w.Header().Set(echo.HeaderContentEncoding, "gzip")
		} else {
			contentLength = o["original-content-length"]
			var gr *gzip.Reader
			gr, err = gzip.NewReader(f)
			if err != nil {
				return err
			}
			defer gr.Close()
			r = gr
		}
	}

	ext := path.Ext(file)
	if contentType == "" {
		contentType = mime.TypeByExtension(ext)
	}
	if contentType == "text/xml" && ext == ".svg" {
		// override for files with text/xml content because of leading <?xml tag
		contentType = "image/svg+xml"
	}

	size, _ := strconv.ParseInt(contentLength, 10, 64)

	return serveContent(w, req, contentType, size, r)
}

func (s *swiftServer) ServeCodeTarball(w http.ResponseWriter, req *http.Request, slug, version, shasum string) error {
	objName := path.Join(slug, version)
	if shasum != "" {
		objName += "-" + shasum
	}
	objName += ".tgz"

	f, h, err := s.c.ObjectOpen(s.ctx, s.container, objName, false, nil)
	if err == nil {
		defer f.Close()
		contentLength := h["Content-Length"]
		contentType := h["Content-Type"]
		size, _ := strconv.ParseInt(contentLength, 10, 64)

		return serveContent(w, req, contentType, size, f)
	}

	buf, err := prepareTarball(s, slug, version, shasum)
	if err != nil {
		return err
	}
	content, err := io.ReadAll(buf)
	if err != nil {
		return err
	}
	contentType := mime.TypeByExtension(".gz")

	file, err := s.c.ObjectCreate(s.ctx, s.container, objName, true, "", contentType, nil)
	if err == nil {
		_, _ = io.Copy(file, bytes.NewReader(content))
		_ = file.Close()
	}

	return serveContent(w, req, contentType, int64(len(content)), bytes.NewReader(content))
}

func (s *swiftServer) makeObjectName(slug, version, shasum, file string) string {
	basepath := path.Join(slug, version)
	if shasum != "" {
		basepath += "-" + shasum
	}
	return path.Join(basepath, file)
}

func (s *swiftServer) FilesList(slug, version, shasum string) ([]string, error) {
	prefix := s.makeObjectName(slug, version, shasum, "") + "/"
	names, err := s.c.ObjectNamesAll(s.ctx, s.container, &swift.ObjectsOpts{
		Limit:  10_000,
		Prefix: prefix,
	})
	if err != nil {
		return nil, err
	}
	filtered := names[:0]
	for _, n := range names {
		n = strings.TrimPrefix(n, prefix)
		if n != "" {
			filtered = append(filtered, n)
		}
	}
	return filtered, nil
}

// NewAferoFileServer returns a simple wrapper of the afero.Fs interface that
// provides the apps.FileServer interface.
//
// You can provide a makePath method to define how the file name should be
// created from the application's slug, version and file name. If not provided,
// the standard VFS concatenation (starting with vfs.WebappsDirName) is used.
func NewAferoFileServer(fs afero.Fs, makePath func(slug, version, shasum, file string) string) FileServer {
	if makePath == nil {
		makePath = defaultMakePath
	}
	return &aferoServer{
		mkPath: makePath,
		fs:     fs,
	}
}

const (
	uncompressed = iota + 1
	gzipped
	brotlied
)

// openFile opens the give filepath. By default, it is a file compressed with
// brotli (.br), but it can be a file compressed with gzip (.gz, for apps that
// were installed before brotli compression was enabled), or uncompressed (for
// app development with cozy-stack serve --appdir).
func (s *aferoServer) openFile(filepath string) (afero.File, int, error) {
	compression := brotlied
	f, err := s.fs.Open(filepath + ".br")
	if os.IsNotExist(err) {
		compression = gzipped
		f, err = s.fs.Open(filepath + ".gz")
	}
	if os.IsNotExist(err) {
		compression = uncompressed
		f, err = s.fs.Open(filepath)
	}
	return f, compression, err
}

func (s *aferoServer) Open(slug, version, shasum, file string) (io.ReadCloser, error) {
	filepath := s.mkPath(slug, version, shasum, file)
	f, compression, err := s.openFile(filepath)
	if err != nil {
		return nil, err
	}
	switch compression {
	case uncompressed:
		return f, nil
	case gzipped:
		return newGzipReadCloser(f)
	case brotlied:
		return newBrotliReadCloser(f)
	default:
		panic(fmt.Errorf("Unknown compression type: %v", compression))
	}
}

func (s *aferoServer) ServeFileContent(w http.ResponseWriter, req *http.Request, slug, version, shasum, file string) error {
	filepath := s.mkPath(slug, version, shasum, file)
	return s.serveFileContent(w, req, filepath)
}

func (s *aferoServer) serveFileContent(w http.ResponseWriter, req *http.Request, filepath string) error {
	f, compression, err := s.openFile(filepath)
	if err != nil {
		return err
	}
	defer f.Close()

	var content io.Reader
	var size int64
	if checkEtag := req.Header.Get("Cache-Control") == ""; checkEtag {
		var b []byte
		h := md5.New()
		b, err = io.ReadAll(f)
		if err != nil {
			return err
		}
		etag := fmt.Sprintf(`"%s"`, hex.EncodeToString(h.Sum(nil)))
		if web_utils.CheckPreconditions(w, req, etag) {
			return nil
		}
		w.Header().Set("Etag", etag)
		size = int64(len(b))
		content = bytes.NewReader(b)
	} else {
		size, err = f.Seek(0, io.SeekEnd)
		if err != nil {
			return err
		}
		_, err = f.Seek(0, io.SeekStart)
		if err != nil {
			return err
		}
		content = f
	}

	switch compression {
	case uncompressed:
		// Nothing to do
	case gzipped:
		if acceptGzipEncoding(req) {
			w.Header().Set(echo.HeaderContentEncoding, "gzip")
		} else {
			var gr *gzip.Reader
			var b []byte
			gr, err = gzip.NewReader(content)
			if err != nil {
				return err
			}
			defer gr.Close()
			b, err = io.ReadAll(gr)
			if err != nil {
				return err
			}
			size = int64(len(b))
			content = bytes.NewReader(b)
		}
	case brotlied:
		if acceptBrotliEncoding(req) {
			w.Header().Set(echo.HeaderContentEncoding, "br")
		} else {
			var b []byte
			br := brotli.NewReader(content)
			b, err = io.ReadAll(br)
			if err != nil {
				return err
			}
			size = int64(len(b))
			content = bytes.NewReader(b)
		}
	default:
		panic(fmt.Errorf("Unknown compression type: %v", compression))
	}

	contentType := mime.TypeByExtension(path.Ext(filepath))
	return serveContent(w, req, contentType, size, content)
}

func (s *aferoServer) ServeCodeTarball(w http.ResponseWriter, req *http.Request, slug, version, shasum string) error {
	buf, err := prepareTarball(s, slug, version, shasum)
	if err != nil {
		return err
	}

	contentType := mime.TypeByExtension(".gz")

	return serveContent(w, req, contentType, int64(buf.Len()), buf)
}

func (s *aferoServer) FilesList(slug, version, shasum string) ([]string, error) {
	var names []string
	rootPath := s.mkPath(slug, version, shasum, "")
	err := afero.Walk(s.fs, rootPath, func(path string, infos os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if !infos.IsDir() {
			name := strings.TrimPrefix(path, rootPath)
			name = strings.TrimSuffix(name, ".gz")
			name = strings.TrimSuffix(name, ".br")
			names = append(names, name)
		}
		return nil
	})
	return names, err
}

func defaultMakePath(slug, version, shasum, file string) string {
	basepath := path.Join("/", slug, version)
	if shasum != "" {
		basepath += "-" + shasum
	}
	filepath := path.Join("/", file)
	return path.Join(basepath, filepath)
}

func acceptBrotliEncoding(req *http.Request) bool {
	return strings.Contains(req.Header.Get(echo.HeaderAcceptEncoding), "br")
}

func acceptGzipEncoding(req *http.Request) bool {
	return strings.Contains(req.Header.Get(echo.HeaderAcceptEncoding), "gzip")
}

func containerName(appsType consts.AppType) string {
	switch appsType {
	case consts.WebappType:
		return "apps-web"
	case consts.KonnectorType:
		return "apps-konnectors"
	}
	panic("Unknown AppType")
}

func wrapSwiftErr(err error) error {
	if errors.Is(err, swift.ObjectNotFound) || errors.Is(err, swift.ContainerNotFound) {
		return os.ErrNotExist
	}
	return err
}

func prepareTarball(s FileServer, slug, version, shasum string) (*bytes.Buffer, error) {
	filenames, err := s.FilesList(slug, version, shasum)
	if err != nil {
		return nil, err
	}

	buf := &bytes.Buffer{}
	gw := gzip.NewWriter(buf)
	tw := tar.NewWriter(gw)
	now := time.Now()

	for _, filename := range filenames {
		f, err := s.Open(slug, version, shasum, filename)
		if err != nil {
			return nil, err
		}
		content, err := io.ReadAll(f)
		errc := f.Close()
		if err != nil {
			return nil, err
		}
		if errc != nil {
			return nil, errc
		}
		hdr := &tar.Header{
			Name:     filename,
			Mode:     0640,
			Size:     int64(len(content)),
			Typeflag: tar.TypeReg,
			ModTime:  now,
		}
		if err := tw.WriteHeader(hdr); err != nil {
			return nil, err
		}
		if _, err := tw.Write(content); err != nil {
			return nil, err
		}
	}

	if err := tw.Close(); err != nil {
		return nil, err
	}
	if err := gw.Close(); err != nil {
		return nil, err
	}
	return buf, nil
}

// serveContent replies to the request using the content in the provided
// reader. The Content-Length and Content-Type headers are added with the
// provided values.
func serveContent(w http.ResponseWriter, r *http.Request, contentType string, size int64, content io.Reader) error {
	var err error

	h := w.Header()
	if size > 0 {
		h.Set("Content-Length", strconv.FormatInt(size, 10))
	}
	if contentType != "" {
		h.Set("Content-Type", contentType)
	}
	w.WriteHeader(http.StatusOK)
	if r.Method != "HEAD" {
		_, err = io.Copy(w, content)
	}

	return err
}
