//go:build system

package system_test

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"strconv"
	"strings"
	"testing"

	"github.com/rogpeppe/go-internal/testscript"
)

func TestReadScripts(t *testing.T) {
	root := repoRoot(t)
	gcBin := buildOrUseBinary(t, root)
	readRepo := getenvDefault("GC_SYSTEM_REPO", "infra-test/gctest1")
	writeRepo := getenvDefault("GC_SYSTEM_WRITE_REPO", readRepo)

	requireInfraRepo(t, "GC_SYSTEM_REPO", readRepo)
	requireInfraRepo(t, "GC_SYSTEM_WRITE_REPO", writeRepo)

	testscript.Run(t, testscript.Params{
		Dir:       "testdata/read",
		Condition: systemCondition,
		Setup: func(env *testscript.Env) error {
			setupEnv(t, env, root, gcBin, readRepo, writeRepo)
			return nil
		},
		Cmds: systemCmds(),
	})
}

func TestWriteScripts(t *testing.T) {
	if os.Getenv("GC_SYSTEM_WRITE") != "1" {
		t.Skip("set GC_SYSTEM_WRITE=1 to run write-path system scripts")
	}

	root := repoRoot(t)
	gcBin := buildOrUseBinary(t, root)
	readRepo := getenvDefault("GC_SYSTEM_REPO", "infra-test/gctest1")
	writeRepo := getenvDefault("GC_SYSTEM_WRITE_REPO", readRepo)

	requireInfraRepo(t, "GC_SYSTEM_REPO", readRepo)
	requireInfraRepo(t, "GC_SYSTEM_WRITE_REPO", writeRepo)

	testscript.Run(t, testscript.Params{
		Dir:       "testdata/write",
		Condition: systemCondition,
		Setup: func(env *testscript.Env) error {
			setupEnv(t, env, root, gcBin, readRepo, writeRepo)
			return nil
		},
		Cmds: systemCmds(),
	})
}

func systemCmds() map[string]func(ts *testscript.TestScript, neg bool, args []string) {
	return map[string]func(ts *testscript.TestScript, neg bool, args []string){
		"defer-delete-label":      cmdDeferDeleteLabel,
		"defer-close-issue":       cmdDeferCloseIssue,
		"defer-close-issue-title": cmdDeferCloseIssueTitle,
		"json-assert":             cmdJSONAssert,
		"json-value":              cmdJSONValue,
		"json-ok":                 cmdJSONOK,
		"require-infra":           cmdRequireInfra,
		"stdout2env":              cmdStdout2Env,
		"unique-name":             cmdUniqueName,
	}
}

func setupEnv(t *testing.T, env *testscript.Env, root, gcBin, readRepo, writeRepo string) {
	t.Helper()

	home, err := os.UserHomeDir()
	if err != nil {
		t.Fatalf("resolve user home: %v", err)
	}

	env.Setenv("ROOT", root)
	env.Setenv("GC_BIN", gcBin)
	env.Setenv("SYSTEM_REPO", readRepo)
	env.Setenv("WRITE_REPO", writeRepo)
	env.Setenv(homeEnvName(), home)
	copyOptionalEnv(env, "APPDATA")
	copyOptionalEnv(env, "GC_CONFIG_DIR")
	copyOptionalEnv(env, "XDG_CONFIG_HOME")
	copyOptionalEnv(env, "GC_SYSTEM_ASSIGNEE")
}

func buildOrUseBinary(t *testing.T, root string) string {
	t.Helper()

	if gcBin := os.Getenv("GC_BIN"); gcBin != "" {
		return gcBin
	}

	name := "gc"
	if runtime.GOOS == "windows" {
		name += ".exe"
	}
	out := filepath.Join(t.TempDir(), name)
	cmd := exec.Command("go", "build", "-o", out, "./cmd/gc")
	cmd.Dir = root
	if output, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("build gc: %v\n%s", err, output)
	}
	return out
}

func repoRoot(t *testing.T) string {
	t.Helper()

	wd, err := os.Getwd()
	if err != nil {
		t.Fatalf("get working directory: %v", err)
	}
	root, err := filepath.Abs(filepath.Join(wd, "..", ".."))
	if err != nil {
		t.Fatalf("resolve repository root: %v", err)
	}
	return root
}

func getenvDefault(name, fallback string) string {
	if value := os.Getenv(name); value != "" {
		return value
	}
	return fallback
}

func copyOptionalEnv(env *testscript.Env, name string) {
	if value := os.Getenv(name); value != "" {
		env.Setenv(name, value)
	}
}

func systemCondition(condition string) (bool, error) {
	name, ok := strings.CutPrefix(condition, "env:")
	if !ok || name == "" {
		return false, fmt.Errorf("unknown condition %q", condition)
	}
	return os.Getenv(name) != "", nil
}

func requireInfraRepo(t *testing.T, name, repo string) {
	t.Helper()

	if err := validateInfraRepo(name, repo); err != nil {
		t.Fatal(err)
	}
}

func validateInfraRepo(name, repo string) error {
	if repo == "" {
		return fmt.Errorf("%s is required", name)
	}
	if !strings.HasPrefix(repo, "infra-test/") {
		return fmt.Errorf("%s must be infra-test/*, got %q", name, repo)
	}
	if repo == "infra-test/" || strings.Count(repo, "/") != 1 {
		return fmt.Errorf("%s must be an owner/repo path under infra-test, got %q", name, repo)
	}
	return nil
}

func homeEnvName() string {
	if runtime.GOOS == "windows" {
		return "USERPROFILE"
	}
	return "HOME"
}

func cmdRequireInfra(ts *testscript.TestScript, neg bool, args []string) {
	if len(args) != 1 {
		ts.Fatalf("usage: require-infra repo")
	}
	err := validateInfraRepo("repo", args[0])
	if neg {
		if err == nil {
			ts.Fatalf("%q unexpectedly passed infra-test validation", args[0])
		}
		return
	}
	if err != nil {
		ts.Fatalf("%v", err)
	}
}

func cmdJSONOK(ts *testscript.TestScript, neg bool, args []string) {
	if len(args) != 1 {
		ts.Fatalf("usage: json-ok file")
	}
	var value any
	err := json.Unmarshal([]byte(ts.ReadFile(args[0])), &value)
	if neg {
		if err == nil {
			ts.Fatalf("%s is valid JSON", args[0])
		}
		return
	}
	if err != nil {
		ts.Fatalf("%s is not valid JSON: %v", args[0], err)
	}
}

func cmdJSONAssert(ts *testscript.TestScript, neg bool, args []string) {
	if len(args) != 3 {
		ts.Fatalf("usage: json-assert file path type")
	}

	value, err := parseJSONFile(ts, args[0])
	if err != nil {
		ts.Fatalf("%s is not valid JSON: %v", args[0], err)
	}
	actual, ok, err := lookupJSONPath(value, args[1])
	if err != nil {
		ts.Fatalf("invalid JSON path %q: %v", args[1], err)
	}
	matches := ok && jsonTypeMatches(actual, args[2])
	if neg {
		if matches {
			ts.Fatalf("%s %s unexpectedly matched type %s", args[0], args[1], args[2])
		}
		return
	}
	if !ok {
		ts.Fatalf("%s %s is missing", args[0], args[1])
	}
	if !matches {
		ts.Fatalf("%s %s has type %s, want %s", args[0], args[1], jsonType(actual), args[2])
	}
}

func cmdJSONValue(ts *testscript.TestScript, neg bool, args []string) {
	if len(args) != 3 {
		ts.Fatalf("usage: json-value file path expected")
	}
	value, err := parseJSONFile(ts, args[0])
	if err != nil {
		ts.Fatalf("%s is not valid JSON: %v", args[0], err)
	}
	actual, ok, err := lookupJSONPath(value, args[1])
	if err != nil {
		ts.Fatalf("invalid JSON path %q: %v", args[1], err)
	}
	matches := ok && fmt.Sprint(actual) == args[2]
	if neg {
		if matches {
			ts.Fatalf("%s %s unexpectedly equals %q", args[0], args[1], args[2])
		}
		return
	}
	if !ok {
		ts.Fatalf("%s %s is missing", args[0], args[1])
	}
	if !matches {
		ts.Fatalf("%s %s = %q, want %q", args[0], args[1], fmt.Sprint(actual), args[2])
	}
}

func parseJSONFile(ts *testscript.TestScript, file string) (any, error) {
	var value any
	err := json.Unmarshal([]byte(ts.ReadFile(file)), &value)
	return value, err
}

func lookupJSONPath(value any, path string) (any, bool, error) {
	if path == "." || path == "" {
		return value, true, nil
	}
	for path != "" {
		switch {
		case strings.HasPrefix(path, "."):
			path = path[1:]
			key, rest := nextJSONKey(path)
			if key == "" {
				return nil, false, fmt.Errorf("empty object key")
			}
			object, ok := value.(map[string]any)
			if !ok {
				return nil, false, nil
			}
			value, ok = object[key]
			if !ok {
				return nil, false, nil
			}
			path = rest
		case strings.HasPrefix(path, "["):
			end := strings.Index(path, "]")
			if end < 0 {
				return nil, false, fmt.Errorf("missing closing ]")
			}
			index, err := parseJSONIndex(path[1:end])
			if err != nil {
				return nil, false, err
			}
			array, ok := value.([]any)
			if !ok || index < 0 || index >= len(array) {
				return nil, false, nil
			}
			value = array[index]
			path = path[end+1:]
		default:
			key, rest := nextJSONKey(path)
			if key == "" {
				return nil, false, fmt.Errorf("empty object key")
			}
			object, ok := value.(map[string]any)
			if !ok {
				return nil, false, nil
			}
			value, ok = object[key]
			if !ok {
				return nil, false, nil
			}
			path = rest
		}
	}
	return value, true, nil
}

func nextJSONKey(path string) (string, string) {
	nextDot := strings.Index(path, ".")
	nextBracket := strings.Index(path, "[")
	next := -1
	switch {
	case nextDot >= 0 && nextBracket >= 0:
		next = minInt(nextDot, nextBracket)
	case nextDot >= 0:
		next = nextDot
	case nextBracket >= 0:
		next = nextBracket
	}
	if next < 0 {
		return path, ""
	}
	return path[:next], path[next:]
}

func parseJSONIndex(value string) (int, error) {
	index, err := strconv.Atoi(value)
	if err != nil {
		return 0, fmt.Errorf("invalid array index %q", value)
	}
	return index, nil
}

func jsonTypeMatches(value any, want string) bool {
	switch want {
	case "present":
		return true
	case "string":
		_, ok := value.(string)
		return ok
	case "nonempty-string":
		text, ok := value.(string)
		return ok && text != ""
	case "number":
		_, ok := value.(float64)
		return ok
	case "bool":
		_, ok := value.(bool)
		return ok
	case "object":
		_, ok := value.(map[string]any)
		return ok
	case "array":
		_, ok := value.([]any)
		return ok
	case "null":
		return value == nil
	default:
		return false
	}
}

func jsonType(value any) string {
	switch value.(type) {
	case string:
		return "string"
	case float64:
		return "number"
	case bool:
		return "bool"
	case map[string]any:
		return "object"
	case []any:
		return "array"
	case nil:
		return "null"
	default:
		return fmt.Sprintf("%T", value)
	}
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

func cmdStdout2Env(ts *testscript.TestScript, neg bool, args []string) {
	if neg {
		ts.Fatalf("unsupported: ! stdout2env")
	}
	if len(args) != 2 {
		ts.Fatalf("usage: stdout2env VAR regexp-with-one-capture")
	}
	re, err := regexp.Compile(args[1])
	ts.Check(err)
	matches := re.FindStringSubmatch(ts.ReadFile("stdout"))
	if len(matches) < 2 {
		ts.Fatalf("stdout did not match %q", args[1])
	}
	ts.Setenv(args[0], matches[1])
}

func cmdDeferCloseIssue(ts *testscript.TestScript, neg bool, args []string) {
	if neg {
		ts.Fatalf("unsupported: ! defer-close-issue")
	}
	if len(args) != 1 {
		ts.Fatalf("usage: defer-close-issue issue-number")
	}
	issueNumber := args[0]
	gcBin := ts.Getenv("GC_BIN")
	writeRepo := ts.Getenv("WRITE_REPO")
	ts.Defer(func() {
		if err := closeIssueIfOpen(gcBin, writeRepo, issueNumber); err != nil {
			ts.Fatalf("close issue %s during cleanup: %v", issueNumber, err)
		}
	})
}

func cmdDeferCloseIssueTitle(ts *testscript.TestScript, neg bool, args []string) {
	if neg {
		ts.Fatalf("unsupported: ! defer-close-issue-title")
	}
	if len(args) != 1 {
		ts.Fatalf("usage: defer-close-issue-title exact-title")
	}
	title := args[0]
	gcBin := ts.Getenv("GC_BIN")
	writeRepo := ts.Getenv("WRITE_REPO")
	ts.Defer(func() {
		output, err := exec.Command(
			gcBin, "issue", "list", "-R", writeRepo, "--state", "all", "--search", title, "--limit", "100", "--json",
		).Output()
		if err != nil {
			ts.Fatalf("list issues during title cleanup: %v", err)
		}
		var issues []struct {
			Number string `json:"number"`
			Title  string `json:"title"`
			State  string `json:"state"`
		}
		if err := json.Unmarshal(output, &issues); err != nil {
			ts.Fatalf("parse issue list during title cleanup: %v", err)
		}
		for _, issue := range issues {
			if issue.Title != title || issue.State != "open" {
				continue
			}
			if err := closeIssueIfOpen(gcBin, writeRepo, issue.Number); err != nil {
				ts.Fatalf("close issue %s during title cleanup: %v", issue.Number, err)
			}
		}
	})
}

func closeIssueIfOpen(gcBin, repo, number string) error {
	output, err := exec.Command(gcBin, "issue", "view", number, "-R", repo, "--json").Output()
	if err != nil {
		return fmt.Errorf("view issue: %w", err)
	}
	var issue struct {
		State string `json:"state"`
	}
	if err := json.Unmarshal(output, &issue); err != nil {
		return fmt.Errorf("parse issue: %w", err)
	}
	if issue.State == "closed" {
		return nil
	}
	closeOutput, err := exec.Command(gcBin, "issue", "close", number, "-R", repo, "--yes").CombinedOutput()
	if err != nil {
		return fmt.Errorf("close issue: %w\n%s", err, closeOutput)
	}
	return nil
}

func cmdDeferDeleteLabel(ts *testscript.TestScript, neg bool, args []string) {
	if neg {
		ts.Fatalf("unsupported: ! defer-delete-label")
	}
	if len(args) != 1 {
		ts.Fatalf("usage: defer-delete-label label-name")
	}
	labelName := args[0]
	gcBin := ts.Getenv("GC_BIN")
	writeRepo := ts.Getenv("WRITE_REPO")
	ts.Defer(func() {
		run := func(args ...string) ([]byte, error) {
			return exec.Command(gcBin, args...).CombinedOutput()
		}
		if err := deleteLabelIfExists(run, writeRepo, labelName); err != nil {
			ts.Fatalf("delete label %q during cleanup: %v", labelName, err)
		}
	})
}

type systemCommandRunner func(args ...string) ([]byte, error)

func deleteLabelIfExists(run systemCommandRunner, repo, labelName string) error {
	const pageSize = 100
	for page := 1; ; page++ {
		output, err := run(
			"label", "list", "-R", repo,
			"--limit", strconv.Itoa(pageSize), "--page", strconv.Itoa(page), "--json",
		)
		if err != nil {
			return fmt.Errorf("list labels page %d: %w\n%s", page, err, output)
		}
		var labels []struct {
			Name string `json:"name"`
		}
		if err := json.Unmarshal(output, &labels); err != nil {
			return fmt.Errorf("parse label list page %d: %w", page, err)
		}
		for _, label := range labels {
			if label.Name != labelName {
				continue
			}
			deleteOutput, err := run("label", "delete", labelName, "-R", repo, "--yes")
			if err != nil {
				return fmt.Errorf("delete label: %w\n%s", err, deleteOutput)
			}
			return nil
		}
		if len(labels) < pageSize {
			return nil
		}
	}
}

func cmdUniqueName(ts *testscript.TestScript, neg bool, args []string) {
	if neg {
		ts.Fatalf("unsupported: ! unique-name")
	}
	if len(args) < 2 || len(args) > 3 {
		ts.Fatalf("usage: unique-name VAR prefix [max-length]")
	}
	maxLength := 0
	if len(args) == 3 {
		var err error
		maxLength, err = parsePositiveInt(args[2])
		if err != nil {
			ts.Fatalf("invalid max length: %v", err)
		}
	}
	name, err := uniqueName(args[1], ts.Name(), os.Getpid(), maxLength)
	if err != nil {
		ts.Fatalf("generate unique name: %v", err)
	}
	ts.Setenv(args[0], name)
}

func parsePositiveInt(value string) (int, error) {
	parsed, err := strconv.Atoi(value)
	if err != nil || parsed <= 0 {
		return 0, fmt.Errorf("must be a positive integer: %q", value)
	}
	return parsed, nil
}

func uniqueName(prefix, testName string, pid, maxLength int) (string, error) {
	var nonce [16]byte
	if _, err := rand.Read(nonce[:]); err != nil {
		return "", err
	}
	base := prefix + "-" + testName
	suffix := fmt.Sprintf("-%d-%s", pid, hex.EncodeToString(nonce[:]))
	if maxLength == 0 {
		return base + suffix, nil
	}

	baseRunes := []rune(base)
	suffixRunes := []rune(suffix)
	if maxLength <= len(suffixRunes) {
		return "", fmt.Errorf("max length %d is too small for unique suffix", maxLength)
	}
	if available := maxLength - len(suffixRunes); len(baseRunes) > available {
		baseRunes = baseRunes[:available]
	}
	trimmedBase := strings.TrimRight(string(baseRunes), "-")
	if trimmedBase == "" {
		return "", fmt.Errorf("max length %d leaves no readable prefix", maxLength)
	}
	return trimmedBase + suffix, nil
}