// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package uiserver

import (
	"bytes"
	"embed"
	"encoding/json"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"os"
	"path"
	"strings"
	"sync/atomic"
	"text/template"

	"github.com/hashicorp/go-hclog"

	"github.com/hashicorp/consul/agent/config"
	"github.com/hashicorp/consul/logging"
)

const (
	compiledProviderJSPath = "assets/compiled-metrics-providers.js"
)

//go:embed dist
var dist embed.FS

// Handler is the http.Handler that serves the Consul UI. It may serve from the
// embedded fs.FS or from an external directory. It provides a few important
// transformations on the index.html file and includes a proxy for metrics
// backends.
type Handler struct {
	// runtimeConfig is a struct accessed through an atomic value to make
	// it safe to reload at run time. Each call to ServeHTTP will see the latest
	// version of the state without internal locking needed.
	runtimeConfig atomic.Value
	logger        hclog.Logger
	transform     UIDataTransform
}

// UIDataTransform is an optional dependency that allows the agent to add
// additional data into the UI index as needed. For example we use this to
// inject enterprise-only feature flags into the template without making this
// package inherently dependent on Enterprise-only code.
//
// It is passed the current RuntimeConfig being applied and a map containing the
// current data that will be passed to the template. It should be modified
// directly to inject additional context.
type UIDataTransform func(data map[string]interface{}) error

// NewHandler returns a Handler that can be used to serve UI http requests. It
// accepts a full agent config since properties like ACLs being enabled affect
// the UI so we need more than just UIConfig parts.
func NewHandler(runtimeCfg *config.RuntimeConfig, logger hclog.Logger, transform UIDataTransform) *Handler {
	h := &Handler{
		logger:    logger.Named(logging.UIServer),
		transform: transform,
	}
	h.runtimeConfig.Store(runtimeCfg)
	return h
}

// ServeHTTP implements http.Handler and serves UI HTTP requests
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// We need to support the path being trimmed by http.StripTags just like the
	// file servers do since http.StripPrefix will remove the leading slash in our
	// current config. Everything else works fine that way so we should to.
	pathTrimmed := strings.TrimLeft(r.URL.Path, "/")
	if pathTrimmed == compiledProviderJSPath {
		h.serveUIMetricsProviders(w)
		return
	}

	if strings.HasPrefix(pathTrimmed, "assets/chunk") && strings.HasSuffix(pathTrimmed, ".js") {
		h.serveTransformedJS(w, pathTrimmed)
		return
	}

	srv, err := h.handleIndex()
	if err != nil {
		http.Error(w, "UI server is misconfigured.", http.StatusInternalServerError)
		h.logger.Error("Failed to configure UI server: %s", err)
		return
	}
	srv.ServeHTTP(w, r)
}

// ReloadConfig is called by the agent when the configuration is reloaded and
// updates the UIConfig values the handler uses to serve requests.
func (h *Handler) ReloadConfig(newCfg *config.RuntimeConfig) error {
	h.runtimeConfig.Store(newCfg)
	return nil
}

func (h *Handler) handleIndex() (http.Handler, error) {
	cfg := h.getRuntimeConfig()

	var fsys fs.FS
	if cfg.UIConfig.Dir == "" {
		// strip the dist/ prefix
		sub, err := fs.Sub(dist, "dist")
		if err != nil {
			return nil, err
		}
		fsys = sub
	} else {
		fsys = os.DirFS(cfg.UIConfig.Dir)
	}

	// Render a new index.html with the new config values ready to serve.
	buf, err := h.renderIndexFile(cfg, fsys)
	if _, ok := err.(*os.PathError); ok && cfg.UIConfig.Dir != "" {
		// A Path error indicates that there is no index.html. This could happen if
		// the user configured their own UI dir and is serving something that is not
		// our usual UI. This won't work perfectly because our uiserver will still
		// redirect everything to the UI but we shouldn't fail the entire UI server
		// with a 500 in this case. Partly that's just bad UX and partly it's a
		// breaking change although quite an edge case. Instead, continue but just
		// return a 404 response for the index.html and log a warning.
		h.logger.Warn("ui_config.dir does not contain an index.html. Index templating and redirects to index.html are disabled.")
		return http.FileServer(http.FS(fsys)), nil
	}
	if err != nil {
		return nil, err
	}

	// Create a new fsys that serves the rendered index file or falls back to the
	// underlying FS.
	fsys = &bufIndexFS{
		fs:       fsys,
		bufIndex: buf,
	}

	// Wrap the buffering FS our redirect FS. This needs to happen later so that
	// redirected requests for /index.html get served the rendered version not the
	// original.
	return http.FileServer(http.FS(&redirectFS{fs: fsys})), nil
}

// getRuntimeConfig is a helper to atomically access the runtime config.
func (h *Handler) getRuntimeConfig() *config.RuntimeConfig {
	if cfg, ok := h.runtimeConfig.Load().(*config.RuntimeConfig); ok {
		return cfg
	}
	return nil
}

func (h *Handler) serveUIMetricsProviders(resp http.ResponseWriter) {
	// Reload config in case it's changed
	cfg := h.getRuntimeConfig()

	if len(cfg.UIConfig.MetricsProviderFiles) < 1 {
		http.Error(resp, "No provider JS files configured", http.StatusNotFound)
		return
	}

	var buf bytes.Buffer

	// Open each one and concatenate them
	for _, file := range cfg.UIConfig.MetricsProviderFiles {
		if err := concatFile(&buf, file); err != nil {
			http.Error(resp, "Internal Server Error", http.StatusInternalServerError)
			h.logger.Error("failed serving metrics provider js file", "file", file, "error", err)
			return
		}
	}
	// Done!
	resp.Header()["Content-Type"] = []string{"application/javascript"}
	_, err := buf.WriteTo(resp)
	if err != nil {
		http.Error(resp, "Internal Server Error", http.StatusInternalServerError)
		h.logger.Error("failed writing ui metrics provider files: %s", err)
		return
	}
}

func concatFile(buf *bytes.Buffer, file string) error {
	base := path.Base(file)
	_, err := buf.WriteString("// " + base + "\n\n")
	if err != nil {
		return fmt.Errorf("failed writing provider JS files: %w", err)
	}

	// Attempt to open the file
	f, err := os.Open(file)
	if err != nil {
		return fmt.Errorf("failed opening ui metrics provider JS file: %w", err)
	}
	defer f.Close()
	_, err = buf.ReadFrom(f)
	if err != nil {
		return fmt.Errorf("failed reading ui metrics provider JS file: %w", err)
	}
	_, err = buf.WriteString("\n\n")
	if err != nil {
		return fmt.Errorf("failed writing provider JS files: %w", err)
	}
	return nil
}

func (h *Handler) renderIndexFile(cfg *config.RuntimeConfig, fsys fs.FS) (fs.File, error) {
	// Open the original index.html
	f, err := fsys.Open("index.html")
	if err != nil {
		return nil, err
	}
	defer f.Close()

	content, err := io.ReadAll(f)
	if err != nil {
		return nil, fmt.Errorf("failed reading index.html: %w", err)
	}

	info, err := f.Stat()
	if err != nil {
		return nil, fmt.Errorf("failed reading metadata for index.html: %w", err)
	}

	// Create template data from the current config.
	tplData, err := uiTemplateDataFromConfig(cfg)
	if err != nil {
		return nil, fmt.Errorf("failed loading UI config for template: %w", err)
	}

	// Allow caller to apply additional data transformations if needed.
	if h.transform != nil {
		if err := h.transform(tplData); err != nil {
			return nil, fmt.Errorf("failed running transform: %w", err)
		}
	}

	tpl, err := template.New("index").Funcs(template.FuncMap{
		"jsonEncode": func(data map[string]interface{}) (string, error) {
			bs, err := json.MarshalIndent(data, "", "  ")
			if err != nil {
				return "", fmt.Errorf("failed jsonEncode: %w", err)
			}
			return string(bs), nil
		},
	}).Parse(string(content))
	if err != nil {
		return nil, fmt.Errorf("failed parsing index.html template: %w", err)
	}

	var buf bytes.Buffer

	err = tpl.Execute(&buf, tplData)
	if err != nil {
		return nil, fmt.Errorf("failed to render index.html: %w", err)
	}

	file := newBufferedFile(&buf, info)
	return file, nil
}

// serveTransformedJS serves a transformed version of the specified JS file.
// It replaces certain keys in the JS file with values from the runtime config.
// This is used to inject the root URL and other dynamic values into the JS file
// so that it can be served correctly in different environments.
func (h *Handler) serveTransformedJS(w http.ResponseWriter, jsPath string) {
	cfg := h.getRuntimeConfig()

	var fsys fs.FS
	if cfg.UIConfig.Dir == "" {
		sub, err := fs.Sub(dist, "dist")
		if err != nil {
			h.logger.Error("Failed to open embedded dist: %w", err)
			return
		}
		fsys = sub
	} else {
		fsys = os.DirFS(cfg.UIConfig.Dir)
	}

	f, err := fsys.Open(jsPath)
	if err != nil {
		http.Error(w, "JS file not found", http.StatusNotFound)
		h.logger.Error("JS file not found: %w", err)
		return
	}
	defer f.Close()

	content, err := io.ReadAll(f)
	if err != nil {
		http.Error(w, "Failed to read JS file", http.StatusInternalServerError)
		h.logger.Error("Failed to read JS file: %w", err)
		return
	}

	tplDataFull, err := uiTemplateDataFromConfig(cfg)
	if err != nil {
		http.Error(w, "Failed to load template data", http.StatusInternalServerError)
		h.logger.Error("Failed to load template data: %w", err)
		return
	}
	if h.transform != nil {
		if err := h.transform(tplDataFull); err != nil {
			http.Error(w, "Failed to run transform", http.StatusInternalServerError)
			h.logger.Error("Failed to run transform: %w", err)
			return
		}
	}
	contentPath, isSuccess := tplDataFull["ContentPath"].(string)

	if !isSuccess {
		http.Error(w, "ContentPath value not found in template data", http.StatusInternalServerError)
		h.logger.Error("ContentPath value not found in template data")
		return
	}
	js := string(content)
	js = strings.ReplaceAll(js, "{{.ContentPath}}", contentPath)
	w.Header().Set("Content-Type", "application/javascript")
	if _, err := w.Write([]byte(js)); err != nil {
		h.logger.Error("Failed to write JS response: %w", err)
	}
}
