// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

// Package qnap contains dist Targets for building QNAP Tailscale packages.
//
// QNAP dev docs over at https://www.qnap.com/en/how-to/tutorial/article/qpkg-development-guidelines.
package qnap

import (
	"embed"
	"fmt"
	"io/fs"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"sync"

	"tailscale.com/release/dist"
)

type target struct {
	goenv  map[string]string
	arch   string
	signer *signer
}

type signer struct {
	gcloudCredentialsBase64         string
	gcloudProject                   string
	gcloudKeyring                   string
	keyName                         string
	certificateBase64               string
	certificateIntermediariesBase64 string
}

func (t *target) String() string {
	return fmt.Sprintf("qnap/%s", t.arch)
}

func (t *target) Build(b *dist.Build) ([]string, error) {
	// Stop early if we don't have docker running.
	if _, err := exec.LookPath("docker"); err != nil {
		return nil, fmt.Errorf("docker not found, cannot build: %w", err)
	}

	qnapBuilds := getQnapBuilds(b, t.signer)
	inner, err := qnapBuilds.buildInnerPackage(b, t.goenv)
	if err != nil {
		return nil, err
	}

	return t.buildQPKG(b, qnapBuilds, inner)
}

const (
	qnapTag = "1" // currently static, we don't seem to bump this
)

func (t *target) buildQPKG(b *dist.Build, qnapBuilds *qnapBuilds, inner *innerPkg) ([]string, error) {
	if _, err := exec.LookPath("docker"); err != nil {
		return nil, fmt.Errorf("docker not found, cannot build: %w", err)
	}

	if err := qnapBuilds.makeDockerImage(b); err != nil {
		return nil, fmt.Errorf("makeDockerImage: %w", err)
	}

	filename := fmt.Sprintf("Tailscale_%s-%s_%s.qpkg", b.Version.Short, qnapTag, t.arch)
	filePath := filepath.Join(b.Out, filename)

	args := []string{"run", "--rm",
		"--network=host",
		"-e", fmt.Sprintf("ARCH=%s", t.arch),
		"-e", fmt.Sprintf("TSTAG=%s", b.Version.Short),
		"-e", fmt.Sprintf("QNAPTAG=%s", qnapTag),
		"-v", fmt.Sprintf("%s:/tailscale", inner.tailscalePath),
		"-v", fmt.Sprintf("%s:/tailscaled", inner.tailscaledPath),
		// Tailscale folder has QNAP package setup files needed for building.
		"-v", fmt.Sprintf("%s:/Tailscale", filepath.Join(qnapBuilds.tmpDir, "files/Tailscale")),
		"-v", fmt.Sprintf("%s:/build-qpkg.sh", filepath.Join(qnapBuilds.tmpDir, "files/scripts/build-qpkg.sh")),
		"-v", fmt.Sprintf("%s:/out", b.Out),
	}

	if t.signer != nil {
		log.Println("Will sign with Google Cloud HSM")
		args = append(args,
			"-e", fmt.Sprintf("GCLOUD_CREDENTIALS_BASE64=%s", t.signer.gcloudCredentialsBase64),
			"-e", fmt.Sprintf("GCLOUD_PROJECT=%s", t.signer.gcloudProject),
			"-e", fmt.Sprintf("GCLOUD_KEYRING=%s", t.signer.gcloudKeyring),
			"-e", fmt.Sprintf("QNAP_SIGNING_KEY_NAME=%s", t.signer.keyName),
			"-e", fmt.Sprintf("QNAP_SIGNING_CERT_BASE64=%s", t.signer.certificateBase64),
			"-e", fmt.Sprintf("QNAP_SIGNING_CERT_INTERMEDIARIES_BASE64=%s", t.signer.certificateIntermediariesBase64),
			"-e", fmt.Sprintf("QNAP_SIGNING_SCRIPT=%s", "/sign-qpkg.sh"),
			"-v", fmt.Sprintf("%s:/sign-qpkg.sh", filepath.Join(qnapBuilds.tmpDir, "files/scripts/sign-qpkg.sh")),
		)
	}

	args = append(args,
		"build.tailscale.io/qdk:latest",
		"/build-qpkg.sh",
	)

	cmd := b.Command(b.Repo, "docker", args...)

	// dist.Build runs target builds in parallel goroutines by default.
	// For QNAP, this is an issue because the underlaying qbuild builder will
	// create tmp directories in the shared docker image that end up conflicting
	// with one another.
	// So we use a mutex to only allow one "docker run" at a time.
	qnapBuilds.dockerImageMu.Lock()
	defer qnapBuilds.dockerImageMu.Unlock()

	log.Printf("Building %s", filePath)
	out, err := cmd.CombinedOutput()
	if err != nil {
		return nil, fmt.Errorf("docker run %v: %s", err, out)
	}

	ret := []string{filePath, filePath + ".md5"}
	// If the build was signed, a .codesigning file is produced containing
	// the last 32 characters of the base64-encoded CMS signature. This is
	// used by pkgserve to populate <signature> entries in the QNAP
	// repository XML.
	codesigning := filePath + ".codesigning"
	if _, err := os.Stat(codesigning); err == nil {
		ret = append(ret, codesigning)
	}
	return ret, nil
}

type qnapBuildsMemoizeKey struct{}

type innerPkg struct {
	tailscalePath  string
	tailscaledPath string
}

// qnapBuilds holds extra build context shared by all qnap builds.
type qnapBuilds struct {
	// innerPkgs contains per-goenv compiled binary paths.
	// It is used to avoid repeated compilations for the same architecture.
	innerPkgs     dist.Memoize[*innerPkg]
	dockerImageMu sync.Mutex
	// tmpDir is a temp directory used for building qpkgs.
	// It gets cleaned up when the dist.Build is closed.
	tmpDir string
}

// getQnapBuilds returns the qnapBuilds for b, creating one if needed.
func getQnapBuilds(b *dist.Build, signer *signer) *qnapBuilds {
	return b.Extra(qnapBuildsMemoizeKey{}, func() any {
		builds, err := newQNAPBuilds(b, signer)
		if err != nil {
			panic(fmt.Errorf("setUpTmpDir: %v", err))
		}
		return builds
	}).(*qnapBuilds)
}

//go:embed all:files
var buildFiles embed.FS

// newQNAPBuilds creates a new qnapBuilds instance to hold context shared by
// all qnap targets, and sets up its local temp directory used for building.
//
// The qnapBuilds.tmpDir is filled with the contents of the buildFiles embedded
// FS for building.
//
// We do this to allow for this tailscale.com/release/dist/qnap package to be
// used from both the corp and OSS repos. When built from OSS source directly,
// this is a superfluous extra step, but when imported as a go module to another
// repo (such as corp), we must do this to allow for the module's build files
// to be reachable and editable from docker.
//
// This runs only once per dist.Build instance, is shared by all qnap targets,
// and gets cleaned up upon close of the dist.Build.
//
// When a signer is provided, newQNAPBuilds also sets up the qpkg signature
// files in qbuild's expected location within m.tmpDir.
func newQNAPBuilds(b *dist.Build, signer *signer) (*qnapBuilds, error) {
	m := new(qnapBuilds)

	log.Print("Setting up qnap tmp build directory")
	m.tmpDir = filepath.Join(b.Repo, "tmp-qnap-build")
	b.AddOnCloseFunc(func() error {
		return os.RemoveAll(m.tmpDir)
	})

	if err := fs.WalkDir(buildFiles, "files", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		outPath := filepath.Join(m.tmpDir, path)
		if d.IsDir() {
			return os.MkdirAll(outPath, 0755)
		}
		file, err := fs.ReadFile(buildFiles, path)
		if err != nil {
			return err
		}
		perm := fs.FileMode(0644)
		if slices.Contains([]string{".sh", ".cgi"}, filepath.Ext(path)) {
			perm = 0755
		}
		return os.WriteFile(outPath, file, perm)
	}); err != nil {
		return nil, err
	}

	return m, nil
}

// buildInnerPackage builds the go binaries used for qnap packages.
// These binaries get embedded with Tailscale package metadata to form qnap
// releases.
func (m *qnapBuilds) buildInnerPackage(b *dist.Build, goenv map[string]string) (*innerPkg, error) {
	return m.innerPkgs.Do(goenv, func() (*innerPkg, error) {
		if err := b.BuildWebClientAssets(); err != nil {
			return nil, err
		}
		ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", goenv)
		if err != nil {
			return nil, err
		}
		tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", goenv)
		if err != nil {
			return nil, err
		}

		// The go binaries above get built and put into a /tmp directory created
		// by b.TmpDir(). But, we build QNAP with docker, which doesn't always
		// allow for mounting tmp directories (seemingly dependent on docker
		// host).
		// https://stackoverflow.com/questions/65267251/docker-bind-mount-directory-in-tmp-not-working
		//
		// So here, we move the binaries into a directory within the b.Repo
		// path and clean it up when the builder closes.

		tmpDir := filepath.Join(m.tmpDir, fmt.Sprintf("/binaries-%s-%s-%s", b.Version.Short, goenv["GOOS"], goenv["GOARCH"]))
		if err = os.MkdirAll(tmpDir, 0755); err != nil {
			return nil, err
		}
		b.AddOnCloseFunc(func() error {
			return os.RemoveAll(tmpDir)
		})

		tsBytes, err := os.ReadFile(ts)
		if err != nil {
			return nil, err
		}
		tsdBytes, err := os.ReadFile(tsd)
		if err != nil {
			return nil, err
		}

		tsPath := filepath.Join(tmpDir, "tailscale")
		if err := os.WriteFile(tsPath, tsBytes, 0755); err != nil {
			return nil, err
		}
		tsdPath := filepath.Join(tmpDir, "tailscaled")
		if err := os.WriteFile(tsdPath, tsdBytes, 0755); err != nil {
			return nil, err
		}

		return &innerPkg{tailscalePath: tsPath, tailscaledPath: tsdPath}, nil
	})
}

func (m *qnapBuilds) makeDockerImage(b *dist.Build) error {
	return b.Once("make-qnap-docker-image", func() error {
		log.Printf("Building qnapbuilder docker image")

		cmd := b.Command(b.Repo, "docker", "build",
			"-f", filepath.Join(m.tmpDir, "files/scripts/Dockerfile.qpkg"),
			"-t", "build.tailscale.io/qdk:latest",
			filepath.Join(m.tmpDir, "files/scripts"),
		)
		out, err := cmd.CombinedOutput()
		if err != nil {
			return fmt.Errorf("docker build %v: %s", err, out)
		}
		return nil
	})
}
