package serverruntime

import (
	"go/ast"
	"go/parser"
	"go/token"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"testing"

	"golang.org/x/mod/modfile"
)

// TestCutoverGuardSingleCatalogProducerAndNoCircularSelfSeed preserves the neutral
// transport boundary after retirement of the monolith catalog composition.
func TestCutoverGuardSingleCatalogProducerAndNoCircularSelfSeed(t *testing.T) {
	cmdServerDir := filepath.Join("..", "..", "cmd", "runtime-api")
	serverRuntimeDir := "."
	dialectTransportDir := filepath.Join(apiHostModuleDir(t), "dialecttransport")

	// (1) packCatalogFor / packActive must not be declared in dialecttransport
	// production source.
	for _, banned := range []string{"packCatalogFor", "packActive"} {
		if files := declaringFuncInDir(t, dialectTransportDir, banned); len(files) > 0 {
			t.Fatalf("symbol %q must be DELETED from dialecttransport (the #1987 circular self-seed); still declared in %v", banned, files)
		}
	}

	// Runtime and setup helpers must not directly import L1 dialects.
	for _, dir := range []string{cmdServerDir, serverRuntimeDir} {
		for _, imp := range productionImports(t, dir) {
			if strings.Contains(imp, "/dialect/salesforcev63") ||
				strings.Contains(imp, "nexuscrm/dialect-sf-crm") {
				t.Fatalf("DEFAULT server runtime directly imports %q from %s — the public-core binary must be app-blind; name an L1 dialect only in a product distribution command", imp, dir)
			}
		}
	}

	if sites := countSubstringInProductionGoFiles(t, serverRuntimeDir, "KnownInstalledPackCatalog("); sites != 0 {
		t.Fatalf("retired monolith catalog composition still has %d construction sites", sites)
	}

	// (4) The old placeholder resolver kept future gated gin-free routes impossible:
	// any GateAll/GateAny route would fail mount because the composition root claimed
	// no permission existed. The host must bind the real row-backed catalog resolver.
	for _, path := range productionGoFiles(t, serverRuntimeDir) {
		b, err := os.ReadFile(path)
		if err != nil {
			t.Fatalf("read %s: %v", path, err)
		}
		if strings.Contains(string(b), "func(constants.Permission) bool { return false }") {
			t.Fatalf("%s still passes a deny-all PermissionResolver stub; use permissionResolverFromSystemCatalog", filepath.Base(path))
		}
	}
}

func TestDefaultServerDependencyClosureIsAppBlind(t *testing.T) {
	backendRoot := filepath.Join("..", "..")
	for _, tt := range []struct {
		name             string
		args             []string
		requiresAppPacks bool
	}{
		{name: "default", args: []string{"list", "-deps", "./cmd/runtime-api/"}},
		{name: "crm tag", args: []string{"list", "-tags", "crm", "-deps", "./cmd/runtime-api/"}, requiresAppPacks: true},
	} {
		t.Run(tt.name, func(t *testing.T) {
			if tt.requiresAppPacks && !appPacksDialectSourceAvailable(t) {
				t.Skip("app-packs dialect source checkout is unavailable; CI enforces this closure with app-packs checked out")
			}
			cmd := exec.Command("go", tt.args...)
			cmd.Dir = backendRoot
			out, err := cmd.CombinedOutput()
			if err != nil {
				t.Fatalf("go %s: %v\n%s", strings.Join(tt.args, " "), err, out)
			}
			for _, dep := range strings.Fields(string(out)) {
				if isL1DialectDependency(dep) {
					t.Fatalf("./cmd/runtime-api dep closure includes L1 dialect dependency %q; app composition belongs in explicit product distribution commands", dep)
				}
			}
		})
	}
}

func TestAppDialectImportsStayDistributionOwned(t *testing.T) {
	backendRoot := filepath.Join("..", "..")
	for _, path := range productionGoFilesRecursive(t, backendRoot) {
		rel := filepath.ToSlash(mustRel(t, backendRoot, path))
		for _, imp := range productionImportsFromFile(t, path) {
			if !isL1DialectDependency(imp) {
				continue
			}
			if strings.HasPrefix(rel, "distributions/") {
				continue
			}
			t.Fatalf("%s imports L1 dialect dependency %q; app dialect imports must stay inside the distribution-owned module", rel, imp)
		}
	}
}

func TestAppDistributionImportsStayAtCompositionRoots(t *testing.T) {
	backendRoot := filepath.Join("..", "..")
	for _, path := range productionGoFilesRecursive(t, backendRoot) {
		rel := filepath.ToSlash(mustRel(t, backendRoot, path))
		for _, imp := range productionImportsFromFile(t, path) {
			if !isAppDistributionImport(imp) || appDistributionImportAllowedFrom(rel) {
				continue
			}
			t.Fatalf("%s imports app distribution package %q; app-owned distribution code may only be wired from explicit composition roots", rel, imp)
		}
	}
}

func TestBackendGoModHasNoAppDialectRequiresOrReplaces(t *testing.T) {
	modPath := filepath.Join("..", "..", "go.mod")
	data, err := os.ReadFile(modPath)
	if err != nil {
		t.Fatalf("read %s: %v", modPath, err)
	}
	mod, err := modfile.Parse(modPath, data, nil)
	if err != nil {
		t.Fatalf("parse %s: %v", modPath, err)
	}

	for _, replace := range mod.Replace {
		if isL1DialectDependency(replace.Old.Path) || isL1DialectDependency(replace.New.Path) {
			t.Fatalf("backend/go.mod contains app dialect replace %q => %q; A3 requires distribution-owned app deps outside the platform module", replace.Old.String(), replace.New.String())
		}
	}
	for _, req := range mod.Require {
		if isL1DialectDependency(req.Mod.Path) {
			t.Fatalf("backend/go.mod requires app dialect module %q; app deps must be owned by backend/distributions/go.mod", req.Mod.Path)
		}
	}
}

func TestPublicDocsUseDistributionOwnedCRMCommands(t *testing.T) {
	banned := []string{
		"cd backend && go run -tags crm ./cmd/setup",
		"cd backend && go run -tags crm ./cmd/crm-server",
	}
	docs := []string{repoPath("README.md")}
	docsRoot := repoPath("docs")
	if err := filepath.WalkDir(docsRoot, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || filepath.Ext(path) != ".md" {
			return nil
		}
		docs = append(docs, path)
		return nil
	}); err != nil {
		t.Fatalf("walk docs: %v", err)
	}

	for _, path := range docs {
		data, err := os.ReadFile(path)
		if err != nil {
			t.Fatalf("read %s: %v", path, err)
		}
		for _, needle := range banned {
			if strings.Contains(string(data), needle) {
				t.Fatalf("%s still documents old CRM command %q; CRM setup/server commands must run from backend/distributions with GOWORK=off after A3", filepath.ToSlash(path), needle)
			}
		}
	}
}

func TestGoWorkDoesNotOverrideAppDialectReplaces(t *testing.T) {
	workPath := repoPath("go.work")
	data, err := os.ReadFile(workPath)
	if err != nil {
		t.Fatalf("read %s: %v", workPath, err)
	}
	work, err := modfile.ParseWork(workPath, data, nil)
	if err != nil {
		t.Fatalf("parse %s: %v", workPath, err)
	}
	for _, replace := range work.Replace {
		if isL1DialectDependency(replace.Old.Path) {
			t.Fatalf("go.work must not replace app dialect module %q; workspace-level replaces override backend/go.mod's locked app-packs source paths", replace.Old.String())
		}
	}
	for _, use := range work.Use {
		useDir := use.Path
		if !filepath.IsAbs(useDir) {
			useDir = filepath.Join(filepath.Dir(workPath), useDir)
		}
		useModPath := filepath.Join(useDir, "go.mod")
		useData, err := os.ReadFile(useModPath)
		if err != nil {
			t.Fatalf("read go.work use module %s: %v", useModPath, err)
		}
		useMod, err := modfile.Parse(useModPath, useData, nil)
		if err != nil {
			t.Fatalf("parse go.work use module %s: %v", useModPath, err)
		}
		if useMod.Module != nil && isL1DialectDependency(useMod.Module.Mod.Path) {
			t.Fatalf("go.work must not use app dialect module %q from %q; backend builds must consume transitional app dialects through backend/go.mod's locked app-packs replaces", useMod.Module.Mod.Path, filepath.ToSlash(use.Path))
		}
		if useMod.Module != nil && useMod.Module.Mod.Path == "github.com/nexuscrm/backend" {
			continue
		}
		for _, replace := range useMod.Replace {
			if isL1DialectDependency(replace.Old.Path) {
				t.Fatalf("go.work use module %q must not replace app dialect module %q; workspace module replaces can override backend/go.mod's locked app-packs source paths", filepath.ToSlash(use.Path), replace.Old.String())
			}
		}
	}
}

func appPacksDialectSourceAvailable(t *testing.T) bool {
	t.Helper()
	for _, parts := range [][]string{
		{"nexuscrm-app-packs", "dialects", "dialect-sf-crm", "go.mod"},
		{"nexuscrm-app-packs", "dialects", "dialect-sf-crm-soql", "go.mod"},
	} {
		info, err := os.Stat(repoPath(parts...))
		if err == nil {
			if info.IsDir() {
				return false
			}
			continue
		}
		if os.IsNotExist(err) {
			return false
		}
		t.Fatalf("stat app-packs dialect source: %v", err)
	}
	return true
}

func isAppDistributionImport(imp string) bool {
	return strings.HasPrefix(strings.TrimSpace(imp), "github.com/nexuscrm/backend/distributions/")
}

func appDistributionImportAllowedFrom(rel string) bool {
	if strings.HasPrefix(rel, "distributions/") ||
		strings.HasPrefix(rel, "distributions/cmd/crm-server/") {
		return true
	}
	switch rel {
	case "distributions/cmd/setup/distribution_crm.go":
		return true
	default:
		return false
	}
}

func isL1DialectDependency(dep string) bool {
	dep = strings.TrimSpace(dep)
	return dep == "github.com/nexuscrm/dialect-" ||
		strings.HasPrefix(dep, "github.com/nexuscrm/dialect-") ||
		strings.Contains(dep, "/internal/interfaces/dialect/salesforcev63")
}

func repoPath(parts ...string) string {
	all := append([]string{"..", "..", ".."}, parts...)
	return filepath.Join(all...)
}

func apiHostModuleDir(t *testing.T) string {
	t.Helper()
	cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/pingcap-inc/nexuscrm-api-host")
	cmd.Dir = filepath.Join("..", "..")
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("resolve api-host module dir: %v\n%s", err, out)
	}
	dir := strings.TrimSpace(string(out))
	if dir == "" {
		t.Fatal("resolve api-host module dir: empty module dir")
	}
	return dir
}

// declaringFuncInDir returns the production .go files in dir that declare a top-level
// func named name.
func declaringFuncInDir(t *testing.T, dir, name string) []string {
	t.Helper()
	var hits []string
	for _, path := range productionGoFiles(t, dir) {
		fset := token.NewFileSet()
		file, err := parser.ParseFile(fset, path, nil, 0)
		if err != nil {
			t.Fatalf("parse %s: %v", path, err)
		}
		for _, decl := range file.Decls {
			if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name {
				hits = append(hits, filepath.Base(path))
			}
		}
	}
	return hits
}

// productionImports returns every import path across production .go files in dir.
func productionImports(t *testing.T, dir string) []string {
	t.Helper()
	var imports []string
	for _, path := range productionGoFiles(t, dir) {
		imports = append(imports, productionImportsFromFile(t, path)...)
	}
	return imports
}

func productionImportsFromFile(t *testing.T, path string) []string {
	t.Helper()
	fset := token.NewFileSet()
	file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
	if err != nil {
		t.Fatalf("parse %s: %v", path, err)
	}
	imports := make([]string, 0, len(file.Imports))
	for _, spec := range file.Imports {
		p, err := strconv.Unquote(spec.Path.Value)
		if err != nil {
			t.Fatalf("unquote import %q: %v", spec.Path.Value, err)
		}
		imports = append(imports, p)
	}
	return imports
}

// countSubstringInProductionGoFiles counts raw occurrences of substr across
// production .go files in dir.
func countSubstringInProductionGoFiles(t *testing.T, dir, substr string) int {
	t.Helper()
	total := 0
	for _, path := range productionGoFiles(t, dir) {
		data, err := os.ReadFile(path)
		if err != nil {
			t.Fatalf("read %s: %v", path, err)
		}
		total += strings.Count(string(data), substr)
	}
	return total
}

// productionGoFiles lists non-test .go files directly in dir that belong to the
// DEFAULT build — files carrying a `//go:build crm` constraint are EXCLUDED, because
// this guard asserts facts about the app-blind public-core build (the crm-tagged
// bundle manifest legitimately names the dialect and is compiled only under
// `-tags crm`). go/parser does not evaluate build tags, so the exclusion is done by
// reading the constraint directly.
func productionGoFiles(t *testing.T, dir string) []string {
	t.Helper()
	entries, err := os.ReadDir(dir)
	if err != nil {
		t.Fatalf("read dir %s: %v", dir, err)
	}
	var out []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		n := e.Name()
		if !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") {
			continue
		}
		path := filepath.Join(dir, n)
		if fileHasBuildTag(t, path, "crm") {
			continue
		}
		out = append(out, path)
	}
	return out
}

func productionGoFilesRecursive(t *testing.T, root string) []string {
	t.Helper()
	var out []string
	err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if entry.IsDir() {
			switch entry.Name() {
			case ".git", "node_modules", "vendor":
				return filepath.SkipDir
			default:
				return nil
			}
		}
		name := entry.Name()
		if strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") {
			out = append(out, path)
		}
		return nil
	})
	if err != nil {
		t.Fatalf("walk %s: %v", root, err)
	}
	return out
}

func mustRel(t *testing.T, base, target string) string {
	t.Helper()
	rel, err := filepath.Rel(base, target)
	if err != nil {
		t.Fatalf("rel %s -> %s: %v", base, target, err)
	}
	return rel
}

// fileHasBuildTag reports whether path carries a `//go:build` constraint that names
// tag (a conservative substring match — sufficient for the single-tag `crm`
// constraint this guard cares about).
func fileHasBuildTag(t *testing.T, path, tag string) bool {
	t.Helper()
	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read %s: %v", path, err)
	}
	for _, line := range strings.Split(string(data), "\n") {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, "//go:build ") {
			return strings.Contains(line, tag)
		}
		// The build-constraint comment must appear before the package clause; stop
		// scanning once we hit it.
		if strings.HasPrefix(line, "package ") {
			return false
		}
	}
	return false
}
