// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

//go:build !cmd_go_bootstrap

package doc

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"

	"cmd/go/internal/base"
	"cmd/go/internal/cfg"
	"cmd/go/internal/load"
	"cmd/go/internal/modload"
	"cmd/go/internal/work"
)

// pkgsiteCmdInternalDocVersion controls the version of the golang.org/x/pkgsite/cmd/internal/doc
// module to build in buildPkgsite.
//
// This version is maintained, like other golang.org/x dependencies, via the go.dev/issue/36905 process.
//
// To make that process easier, the exact name and file location of this constant are known
// to the [golang.org/x/build/cmd/updatestd] command. If this constant needs to be renamed
// or moved to another .go file, update that code too.
const pkgsiteCmdInternalDocVersion = "v0.0.0-20260722200430-7c7eca789259"

// pickUnusedPort finds an unused port by trying to listen on port 0
// and letting the OS pick a port, then closing that connection and
// returning that port number.
// This is inherently racy.
func pickUnusedPort() (int, error) {
	l, err := net.Listen("tcp", "localhost:0")
	if err != nil {
		return 0, err
	}
	port := l.Addr().(*net.TCPAddr).Port
	if err := l.Close(); err != nil {
		return 0, err
	}
	return port, nil
}

// buildPkgsite builds a pkgsite binary whose build may be cached.
func buildPkgsite(ctx context.Context) string {
	load.ClearPackageCache()
	loader := modload.NewLoader()

	// Set the builder to have no module root so we can build a pkg@version pattern.
	loader.ForceUseModules = true
	loader.RootMode = modload.NoRoot
	loader.AllowMissingModuleImports()
	modload.Init(loader)

	work.BuildInit(loader)
	b := work.NewBuilder("", loader.VendorDirOrEmpty)
	defer func() {
		if err := b.Close(); err != nil {
			base.Fatal(err)
		}
	}()

	version := pkgsiteCmdInternalDocVersion
	if os.Getenv("TEST_GODOC_BUILD_ONLY") != "" {
		version = "v0.1.0"
	}
	pkgVers := "golang.org/x/pkgsite/cmd/internal/doc@" + version
	pkgOpts := load.PackageOpts{MainOnly: true}
	pkgs, err := load.PackagesAndErrorsOutsideModule(loader, ctx, pkgOpts, []string{pkgVers})
	if err != nil {
		base.Fatal(err)
	}
	if len(pkgs) == 0 {
		base.Fatalf("go: internal error: no packages loaded for %s", pkgVers)
	}
	if len(pkgs) > 1 {
		base.Fatalf("go: internal error: pattern %s matches multiple packages", pkgVers)
	}
	p := pkgs[0]
	p.Internal.OmitDebug = true
	p.Internal.ExeName = p.DefaultExecName()
	load.CheckPackageErrors([]*load.Package{p})

	a := b.LinkAction(loader, work.ModeBuild, work.ModeBuild, p)
	a.CacheExecutable = true
	b.Do(ctx, a)

	// Both paths return an executable in GOCACHE: CachedExecutable is set on
	// fresh builds, while BuiltTarget is set on cache hits.
	if cached := a.CachedExecutable(); cached != "" {
		return cached
	}
	return a.BuiltTarget()
}

func doPkgsite(ctx context.Context, urlPath, fragment string) error {
	port, err := pickUnusedPort()
	if err != nil {
		return fmt.Errorf("failed to find port for documentation server: %v", err)
	}
	addr := fmt.Sprintf("localhost:%d", port)
	path, err := url.JoinPath("http://"+addr, urlPath)
	if err != nil {
		return fmt.Errorf("internal error: failed to construct url: %v", err)
	}
	if fragment != "" {
		path += "#" + fragment
	}

	if file := os.Getenv("TEST_GODOC_URL_FILE"); file != "" {
		return os.WriteFile(file, []byte(path+"\n"), 0666)
	}

	// Turn off the default signal handler for SIGINT (and SIGQUIT on Unix)
	// and instead wait for the child process to handle the signal and
	// exit before exiting ourselves.
	base.StartSigHandlers()

	// Prepend the local download cache to GOPROXY to get around deprecation checks.
	env := os.Environ()
	vars, err := runCmd(env, goCmd(), "env", "GOPROXY", "GOMODCACHE")
	fields := strings.Fields(vars)
	if err == nil && len(fields) == 2 {
		goproxy, gomodcache := fields[0], fields[1]
		gomodcache = filepath.Join(gomodcache, "cache", "download")
		// Convert absolute path to file URL. pkgsite will not accept
		// Windows absolute paths because they look like a host:path remote.
		// TODO(golang.org/issue/32456): use url.FromFilePath when implemented.
		if strings.HasPrefix(gomodcache, "/") {
			gomodcache = "file://" + gomodcache
		} else {
			gomodcache = "file:///" + filepath.ToSlash(gomodcache)
		}
		env = append(env, "GOPROXY="+gomodcache+","+goproxy)
	}

	pkgsite := buildPkgsite(ctx)
	if os.Getenv("TEST_GODOC_BUILD_ONLY") != "" {
		if _, err := os.Stat(pkgsite); err != nil {
			return fmt.Errorf("built pkgsite binary does not exist: %w", err)
		}
		return nil
	}
	cmd := exec.Command(pkgsite, "-gorepo", cfg.GOROOT, "-http", addr, "-open", path)
	cmd.Env = env
	cmd.Stdout = os.Stderr
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		if ee, ok := errors.AsType[*exec.ExitError](err); ok {
			// Exit with the same exit status as pkgsite to avoid
			// printing of "exit status" error messages.
			// Any relevant messages have already been printed
			// to stdout or stderr.
			os.Exit(ee.ExitCode())
		}
		return err
	}

	return nil
}
