package site

import (
	"archive/tar"
	"bytes"
	"context"
	"crypto/sha1" //#nosec // Not used for cryptography.
	"database/sql"
	_ "embed"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"html"
	htmltemplate "html/template"
	"io"
	"io/fs"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"strings"
	"sync"
	"sync/atomic"
	"text/template" // html/template escapes some nonces
	"time"

	"github.com/google/uuid"
	"github.com/justinas/nosurf"
	"github.com/klauspost/compress/zstd"
	"github.com/unrolled/secure"
	"golang.org/x/exp/slices"
	"golang.org/x/sync/errgroup"
	"golang.org/x/sync/singleflight"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/buildinfo"
	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/db2sdk"
	"github.com/coder/coder/v2/coderd/database/dbauthz"
	"github.com/coder/coder/v2/coderd/httpapi"
	"github.com/coder/coder/v2/coderd/httpmw"
	"github.com/coder/coder/v2/codersdk"
)

// We always embed the error page HTML because it it doesn't need to be built,
// and it's tiny and doesn't contribute much to the binary size.
var (
	//go:embed static/error.html
	errorHTML string

	errorTemplate *htmltemplate.Template
)

func init() {
	var err error
	errorTemplate, err = htmltemplate.New("error").Parse(errorHTML)
	if err != nil {
		panic(err)
	}
}

type Options struct {
	BinFS         http.FileSystem
	BinHashes     map[string]string
	Database      database.Store
	SiteFS        fs.FS
	OAuth2Configs *httpmw.OAuth2Configs
	DocsURL       string
}

func New(opts *Options) *Handler {
	handler := &Handler{
		opts:          opts,
		secureHeaders: secureHeaders(),
	}

	// html files are handled by a text/template. Non-html files
	// are served by the default file server.
	var err error
	handler.htmlTemplates, err = findAndParseHTMLFiles(opts.SiteFS)
	if err != nil {
		panic(fmt.Sprintf("Failed to parse html files: %v", err))
	}

	binHashCache := newBinHashCache(opts.BinFS, opts.BinHashes)

	mux := http.NewServeMux()
	mux.Handle("/bin/", http.StripPrefix("/bin", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
		// Convert underscores in the filename to hyphens. We eventually want to
		// change our hyphen-based filenames to underscores, but we need to
		// support both for now.
		r.URL.Path = strings.ReplaceAll(r.URL.Path, "_", "-")

		// Set ETag header to the SHA1 hash of the file contents.
		name := filePath(r.URL.Path)
		if name == "" || name == "/" {
			// Serve the directory listing.
			http.FileServer(opts.BinFS).ServeHTTP(rw, r)
			return
		}
		if strings.Contains(name, "/") {
			// We only serve files from the root of this directory, so avoid any
			// shenanigans by blocking slashes in the URL path.
			http.NotFound(rw, r)
			return
		}
		hash, err := binHashCache.getHash(name)
		if xerrors.Is(err, os.ErrNotExist) {
			http.NotFound(rw, r)
			return
		}
		if err != nil {
			http.Error(rw, err.Error(), http.StatusInternalServerError)
			return
		}

		// ETag header needs to be quoted.
		rw.Header().Set("ETag", fmt.Sprintf(`%q`, hash))

		// http.FileServer will see the ETag header and automatically handle
		// If-Match and If-None-Match headers on the request properly.
		http.FileServer(opts.BinFS).ServeHTTP(rw, r)
	})))
	mux.Handle("/", http.FileServer(http.FS(opts.SiteFS)))

	buildInfo := codersdk.BuildInfoResponse{
		ExternalURL: buildinfo.ExternalURL(),
		Version:     buildinfo.Version(),
	}
	buildInfoResponse, err := json.Marshal(buildInfo)
	if err != nil {
		panic("failed to marshal build info: " + err.Error())
	}
	handler.buildInfoJSON = html.EscapeString(string(buildInfoResponse))
	handler.handler = mux.ServeHTTP

	return handler
}

type Handler struct {
	opts *Options

	secureHeaders *secure.Secure
	handler       http.HandlerFunc
	htmlTemplates *template.Template

	buildInfoJSON string

	AppearanceFetcher func(ctx context.Context) (codersdk.AppearanceConfig, error)
	// RegionsFetcher will attempt to fetch the more detailed WorkspaceProxy data, but will fall back to the
	// regions if the user does not have the correct permissions.
	RegionsFetcher func(ctx context.Context) (any, error)

	Entitlements atomic.Pointer[codersdk.Entitlements]
	Experiments  atomic.Pointer[codersdk.Experiments]
}

func (h *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
	err := h.secureHeaders.Process(rw, r)
	if err != nil {
		return
	}

	// reqFile is the static file requested
	reqFile := filePath(r.URL.Path)
	state := htmlState{
		// Token is the CSRF token for the given request
		CSRF:      csrfState{Token: nosurf.Token(r)},
		BuildInfo: h.buildInfoJSON,
		DocsURL:   h.opts.DocsURL,
	}

	// First check if it's a file we have in our templates
	if h.serveHTML(rw, r, reqFile, state) {
		return
	}

	switch {
	// If requesting binaries, serve straight up.
	case reqFile == "bin" || strings.HasPrefix(reqFile, "bin/"):
		h.handler.ServeHTTP(rw, r)
		return
	// If the original file path exists we serve it.
	case h.exists(reqFile):
		if ShouldCacheFile(reqFile) {
			rw.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
		}
		h.handler.ServeHTTP(rw, r)
		return
	}

	// Serve the file assuming it's an html file
	// This matches paths like `/app/terminal.html`
	r.URL.Path = strings.TrimSuffix(r.URL.Path, "/")
	r.URL.Path += ".html"

	reqFile = filePath(r.URL.Path)
	// All html files should be served by the htmlFile templates
	if h.serveHTML(rw, r, reqFile, state) {
		return
	}

	// If we don't have the file... we should redirect to `/`
	// for our single-page-app.
	r.URL.Path = "/"
	if h.serveHTML(rw, r, "", state) {
		return
	}

	// This will send a correct 404
	h.handler.ServeHTTP(rw, r)
}

// filePath returns the filepath of the requested file.
func filePath(p string) string {
	if !strings.HasPrefix(p, "/") {
		p = "/" + p
	}
	return strings.TrimPrefix(path.Clean(p), "/")
}

func (h *Handler) exists(filePath string) bool {
	f, err := h.opts.SiteFS.Open(filePath)
	if err == nil {
		_ = f.Close()
	}
	return err == nil
}

type htmlState struct {
	CSRF csrfState

	// Below are HTML escaped JSON strings of the respective structs.
	BuildInfo    string
	User         string
	Entitlements string
	Appearance   string
	Experiments  string
	Regions      string
	DocsURL      string
}

type csrfState struct {
	Token string
}

func ShouldCacheFile(reqFile string) bool {
	// Images, favicons and uniquely content hashed bundle assets should be
	// cached. By default, we cache everything in the site/out directory except
	// for deny-listed items enumerated here. The reason for this approach is that
	// cache invalidation techniques should be used by default for all build
	// processed assets. The scenarios where we don't use cache invalidation
	// techniques are one-offs or things that should have invalidation in the
	// future.
	denyListedSuffixes := []string{
		".html",
		"worker.js",
	}

	for _, suffix := range denyListedSuffixes {
		if strings.HasSuffix(reqFile, suffix) {
			return false
		}
	}

	return true
}

func (h *Handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
	if data, err := h.renderHTMLWithState(request, reqPath, state); err == nil {
		if reqPath == "" {
			// Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
			reqPath = "index.html"
		}
		http.ServeContent(resp, request, reqPath, time.Time{}, bytes.NewReader(data))
		return true
	}
	return false
}

// renderWithState will render the file using the given nonce if the file exists
// as a template. If it does not, it will return an error.
func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state htmlState) ([]byte, error) {
	var buf bytes.Buffer
	if filePath == "" {
		filePath = "index.html"
	}
	tmpl := h.htmlTemplates.Lookup(filePath)
	if tmpl == nil {
		return nil, xerrors.Errorf("template %q not found", filePath)
	}

	// Cookies are sent when requesting HTML, so we can get the user
	// and pre-populate the state for the frontend to reduce requests.
	// We use a noop response writer because we don't want to write
	// anything to the response and break the HTML, an error means we
	// simply don't pre-populate the state.
	noopRW := noopResponseWriter{}
	apiKey, actor, ok := httpmw.ExtractAPIKey(noopRW, r, httpmw.ExtractAPIKeyConfig{
		Optional:      true,
		DB:            h.opts.Database,
		OAuth2Configs: h.opts.OAuth2Configs,
		// Special case for site, we can always disable refresh here because
		// the frontend will perform API requests if this fails.
		DisableSessionExpiryRefresh: true,
		RedirectToLogin:             false,
		SessionTokenFunc:            nil,
	})
	if ok && apiKey != nil && actor != nil {
		ctx := dbauthz.As(r.Context(), actor.Actor)

		var eg errgroup.Group
		var user database.User
		orgIDs := []uuid.UUID{}
		eg.Go(func() error {
			var err error
			user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID)
			return err
		})
		eg.Go(func() error {
			memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID})
			if errors.Is(err, sql.ErrNoRows) || len(memberIDs) == 0 {
				return nil
			}
			if err != nil {
				return nil
			}
			orgIDs = memberIDs[0].OrganizationIDs
			return err
		})
		err := eg.Wait()
		if err == nil {
			var wg sync.WaitGroup
			wg.Add(1)
			go func() {
				defer wg.Done()
				user, err := json.Marshal(db2sdk.User(user, orgIDs))
				if err == nil {
					state.User = html.EscapeString(string(user))
				}
			}()
			entitlements := h.Entitlements.Load()
			if entitlements != nil {
				wg.Add(1)
				go func() {
					defer wg.Done()
					entitlements, err := json.Marshal(entitlements)
					if err == nil {
						state.Entitlements = html.EscapeString(string(entitlements))
					}
				}()
			}
			if h.AppearanceFetcher != nil {
				wg.Add(1)
				go func() {
					defer wg.Done()
					cfg, err := h.AppearanceFetcher(ctx)
					if err == nil {
						appearance, err := json.Marshal(cfg)
						if err == nil {
							state.Appearance = html.EscapeString(string(appearance))
						}
					}
				}()
			}
			if h.RegionsFetcher != nil {
				wg.Add(1)
				go func() {
					defer wg.Done()
					regions, err := h.RegionsFetcher(ctx)
					if err == nil {
						regions, err := json.Marshal(regions)
						if err == nil {
							state.Regions = html.EscapeString(string(regions))
						}
					}
				}()
			}
			experiments := h.Experiments.Load()
			if experiments != nil {
				wg.Add(1)
				go func() {
					defer wg.Done()
					experiments, err := json.Marshal(experiments)
					if err == nil {
						state.Experiments = html.EscapeString(string(experiments))
					}
				}()
			}
			wg.Wait()
		}
	}

	err := tmpl.Execute(&buf, state)
	if err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

// noopResponseWriter is a response writer that does nothing.
type noopResponseWriter struct{}

func (noopResponseWriter) Header() http.Header         { return http.Header{} }
func (noopResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
func (noopResponseWriter) WriteHeader(int)             {}

// secureHeaders is only needed for statically served files. We do not need this for api endpoints.
// It adds various headers to enforce browser security features.
func secureHeaders() *secure.Secure {
	// Permissions-Policy can be used to disabled various browser features that we do not use.
	// This can prevent an embedded iframe from accessing these features.
	// If we support arbitrary iframes such as generic applications, we might need to add permissions
	// based on the app here.
	permissions := strings.Join([]string{
		// =() means it is disabled
		"accelerometer=()",
		"autoplay=()",
		"battery=()",
		"camera=()",
		"document-domain=()",
		"geolocation=()",
		"gyroscope=()",
		"magnetometer=()",
		"microphone=()",
		"midi=()",
		"payment=()",
		"usb=()",
		"vr=()",
		"screen-wake-lock=()",
		"xr-spatial-tracking=()",
	}, ", ")

	return secure.New(secure.Options{
		PermissionsPolicy: permissions,

		// Prevent the browser from sending Referrer header with requests
		ReferrerPolicy: "no-referrer",
	})
}

// findAndParseHTMLFiles recursively walks the file system passed finding all *.html files.
// The template returned has all html files parsed.
func findAndParseHTMLFiles(files fs.FS) (*template.Template, error) {
	// root is the collection of html templates. All templates are named by their pathing.
	// So './404.html' is named '404.html'. './subdir/index.html' is 'subdir/index.html'
	root := template.New("")

	rootPath := "."
	err := fs.WalkDir(files, rootPath, func(filePath string, directory fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		if directory.IsDir() {
			return nil
		}

		if filepath.Ext(directory.Name()) != ".html" {
			return nil
		}

		file, err := files.Open(filePath)
		if err != nil {
			return err
		}

		data, err := io.ReadAll(file)
		if err != nil {
			return err
		}

		tPath := strings.TrimPrefix(filePath, rootPath+string(filepath.Separator))
		_, err = root.New(tPath).Parse(string(data))
		if err != nil {
			return err
		}

		return nil
	})
	if err != nil {
		return nil, err
	}
	return root, nil
}

// ExtractOrReadBinFS checks the provided fs for compressed coder binaries and
// extracts them into dest/bin if found. As a fallback, the provided FS is
// checked for a /bin directory, if it is non-empty it is returned. Finally
// dest/bin is returned as a fallback allowing binaries to be manually placed in
// dest (usually ${CODER_CACHE_DIRECTORY}/site/bin).
//
// Returns a http.FileSystem that serves unpacked binaries, and a map of binary
// name to SHA1 hash. The returned hash map may be incomplete or contain hashes
// for missing files.
func ExtractOrReadBinFS(dest string, siteFS fs.FS) (http.FileSystem, map[string]string, error) {
	if dest == "" {
		// No destination on fs, embedded fs is the only option.
		binFS, err := fs.Sub(siteFS, "bin")
		if err != nil {
			return nil, nil, xerrors.Errorf("cache path is empty and embedded fs does not have /bin: %w", err)
		}
		return http.FS(binFS), nil, nil
	}

	dest = filepath.Join(dest, "bin")
	mkdest := func() (http.FileSystem, error) {
		err := os.MkdirAll(dest, 0o700)
		if err != nil {
			return nil, xerrors.Errorf("mkdir failed: %w", err)
		}
		return http.Dir(dest), nil
	}

	archive, err := siteFS.Open("bin/coder.tar.zst")
	if err != nil {
		if xerrors.Is(err, fs.ErrNotExist) {
			files, err := fs.ReadDir(siteFS, "bin")
			if err != nil {
				if xerrors.Is(err, fs.ErrNotExist) {
					// Given fs does not have a bin directory, serve from cache
					// directory without extracting anything.
					binFS, err := mkdest()
					if err != nil {
						return nil, nil, xerrors.Errorf("mkdest failed: %w", err)
					}
					return binFS, map[string]string{}, nil
				}
				return nil, nil, xerrors.Errorf("site fs read dir failed: %w", err)
			}

			if len(filterFiles(files, "GITKEEP")) > 0 {
				// If there are other files than bin/GITKEEP, serve the files.
				binFS, err := fs.Sub(siteFS, "bin")
				if err != nil {
					return nil, nil, xerrors.Errorf("site fs sub dir failed: %w", err)
				}
				return http.FS(binFS), nil, nil
			}

			// Nothing we can do, serve the cache directory, thus allowing
			// binaries to be placed there.
			binFS, err := mkdest()
			if err != nil {
				return nil, nil, xerrors.Errorf("mkdest failed: %w", err)
			}
			return binFS, map[string]string{}, nil
		}
		return nil, nil, xerrors.Errorf("open coder binary archive failed: %w", err)
	}
	defer archive.Close()

	binFS, err := mkdest()
	if err != nil {
		return nil, nil, err
	}

	shaFiles, err := parseSHA1(siteFS)
	if err != nil {
		return nil, nil, xerrors.Errorf("parse sha1 file failed: %w", err)
	}

	ok, err := verifyBinSha1IsCurrent(dest, siteFS, shaFiles)
	if err != nil {
		return nil, nil, xerrors.Errorf("verify coder binaries sha1 failed: %w", err)
	}
	if !ok {
		n, err := extractBin(dest, archive)
		if err != nil {
			return nil, nil, xerrors.Errorf("extract coder binaries failed: %w", err)
		}
		if n == 0 {
			return nil, nil, xerrors.New("no files were extracted from coder binaries archive")
		}
	}

	return binFS, shaFiles, nil
}

func filterFiles(files []fs.DirEntry, names ...string) []fs.DirEntry {
	var filtered []fs.DirEntry
	for _, f := range files {
		if slices.Contains(names, f.Name()) {
			continue
		}
		filtered = append(filtered, f)
	}
	return filtered
}

// errHashMismatch is a sentinel error used in verifyBinSha1IsCurrent.
var errHashMismatch = xerrors.New("hash mismatch")

func parseSHA1(siteFS fs.FS) (map[string]string, error) {
	b, err := fs.ReadFile(siteFS, "bin/coder.sha1")
	if err != nil {
		return nil, xerrors.Errorf("read coder sha1 from embedded fs failed: %w", err)
	}

	shaFiles := make(map[string]string)
	for _, line := range bytes.Split(bytes.TrimSpace(b), []byte{'\n'}) {
		parts := bytes.Split(line, []byte{' ', '*'})
		if len(parts) != 2 {
			return nil, xerrors.Errorf("malformed sha1 file: %w", err)
		}
		shaFiles[string(parts[1])] = strings.ToLower(string(parts[0]))
	}
	if len(shaFiles) == 0 {
		return nil, xerrors.Errorf("empty sha1 file: %w", err)
	}

	return shaFiles, nil
}

func verifyBinSha1IsCurrent(dest string, siteFS fs.FS, shaFiles map[string]string) (ok bool, err error) {
	b1, err := fs.ReadFile(siteFS, "bin/coder.sha1")
	if err != nil {
		return false, xerrors.Errorf("read coder sha1 from embedded fs failed: %w", err)
	}
	b2, err := os.ReadFile(filepath.Join(dest, "coder.sha1"))
	if err != nil {
		if xerrors.Is(err, fs.ErrNotExist) {
			return false, nil
		}
		return false, xerrors.Errorf("read coder sha1 failed: %w", err)
	}

	// Check shasum files for equality for early-exit.
	if !bytes.Equal(b1, b2) {
		return false, nil
	}

	var eg errgroup.Group
	// Speed up startup by verifying files concurrently. Concurrency
	// is limited to save resources / early-exit. Early-exit speed
	// could be improved by using a context aware io.Reader and
	// passing the context from errgroup.WithContext.
	eg.SetLimit(3)

	// Verify the hash of each on-disk binary.
	for file, hash1 := range shaFiles {
		file := file
		hash1 := hash1
		eg.Go(func() error {
			hash2, err := sha1HashFile(filepath.Join(dest, file))
			if err != nil {
				if xerrors.Is(err, fs.ErrNotExist) {
					return errHashMismatch
				}
				return xerrors.Errorf("hash file failed: %w", err)
			}
			if !strings.EqualFold(hash1, hash2) {
				return errHashMismatch
			}
			return nil
		})
	}
	err = eg.Wait()
	if err != nil {
		if xerrors.Is(err, errHashMismatch) {
			return false, nil
		}
		return false, err
	}

	return true, nil
}

// sha1HashFile computes a SHA1 hash of the file, returning the hex
// representation.
func sha1HashFile(name string) (string, error) {
	//#nosec // Not used for cryptography.
	hash := sha1.New()
	f, err := os.Open(name)
	if err != nil {
		return "", err
	}
	defer f.Close()

	_, err = io.Copy(hash, f)
	if err != nil {
		return "", err
	}

	b := make([]byte, hash.Size())
	hash.Sum(b[:0])

	return hex.EncodeToString(b), nil
}

func extractBin(dest string, r io.Reader) (numExtracted int, err error) {
	opts := []zstd.DOption{
		// Concurrency doesn't help us when decoding the tar and
		// can actually slow us down.
		zstd.WithDecoderConcurrency(1),
		// Ignoring checksums can give a slight performance
		// boost but it's probably not worth the reduced safety.
		zstd.IgnoreChecksum(false),
		// Allow the decoder to use more memory giving us a 2-3x
		// performance boost.
		zstd.WithDecoderLowmem(false),
	}
	zr, err := zstd.NewReader(r, opts...)
	if err != nil {
		return 0, xerrors.Errorf("open zstd archive failed: %w", err)
	}
	defer zr.Close()

	tr := tar.NewReader(zr)
	n := 0
	for {
		h, err := tr.Next()
		if err != nil {
			if errors.Is(err, io.EOF) {
				return n, nil
			}
			return n, xerrors.Errorf("read tar archive failed: %w", err)
		}
		if h.Name == "." || strings.Contains(h.Name, "..") {
			continue
		}

		name := filepath.Join(dest, filepath.Base(h.Name))
		f, err := os.Create(name)
		if err != nil {
			return n, xerrors.Errorf("create file failed: %w", err)
		}
		//#nosec // We created this tar, no risk of decompression bomb.
		_, err = io.Copy(f, tr)
		if err != nil {
			_ = f.Close()
			return n, xerrors.Errorf("write file contents failed: %w", err)
		}
		err = f.Close()
		if err != nil {
			return n, xerrors.Errorf("close file failed: %w", err)
		}

		n++
	}
}

// ErrorPageData contains the variables that are found in
// site/static/error.html.
type ErrorPageData struct {
	Status int
	// HideStatus will remove the status code from the page.
	HideStatus   bool
	Title        string
	Description  string
	RetryEnabled bool
	DashboardURL string
}

// RenderStaticErrorPage renders the static error page. This is used by app
// requests to avoid dependence on the dashboard but maintain the ability to
// render a friendly error page on subdomains.
func RenderStaticErrorPage(rw http.ResponseWriter, r *http.Request, data ErrorPageData) {
	type outerData struct {
		Error ErrorPageData
	}

	rw.Header().Set("Content-Type", "text/html; charset=utf-8")
	rw.WriteHeader(data.Status)

	err := errorTemplate.Execute(rw, outerData{Error: data})
	if err != nil {
		httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to render error page: " + err.Error(),
			Detail:  fmt.Sprintf("Original error was: %d %s, %s", data.Status, data.Title, data.Description),
		})
		return
	}
}

type binHashCache struct {
	binFS http.FileSystem

	hashes map[string]string
	mut    sync.RWMutex
	sf     singleflight.Group
	sem    chan struct{}
}

func newBinHashCache(binFS http.FileSystem, binHashes map[string]string) *binHashCache {
	b := &binHashCache{
		binFS:  binFS,
		hashes: make(map[string]string, len(binHashes)),
		mut:    sync.RWMutex{},
		sf:     singleflight.Group{},
		sem:    make(chan struct{}, 4),
	}
	// Make a copy since we're gonna be mutating it.
	for k, v := range binHashes {
		b.hashes[k] = v
	}

	return b
}

func (b *binHashCache) getHash(name string) (string, error) {
	b.mut.RLock()
	hash, ok := b.hashes[name]
	b.mut.RUnlock()
	if ok {
		return hash, nil
	}

	// Avoid DOS by using a pool, and only doing work once per file.
	v, err, _ := b.sf.Do(name, func() (interface{}, error) {
		b.sem <- struct{}{}
		defer func() { <-b.sem }()

		f, err := b.binFS.Open(name)
		if err != nil {
			return "", err
		}
		defer f.Close()

		h := sha1.New() //#nosec // Not used for cryptography.
		_, err = io.Copy(h, f)
		if err != nil {
			return "", err
		}

		hash := hex.EncodeToString(h.Sum(nil))
		b.mut.Lock()
		b.hashes[name] = hash
		b.mut.Unlock()
		return hash, nil
	})
	if err != nil {
		return "", err
	}

	//nolint:forcetypeassert
	return strings.ToLower(v.(string)), nil
}
