package cldrlocale_test

import (
	"go/ast"
	"go/parser"
	"go/token"
	"io/fs"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"
)

// Data-shape gate: test the cause of the compile-memory blowup, not the effect.
//
// Two absolute, threshold-free invariants over every generated Go file in the
// repository, identified by the generator header comment:
//
//   - Rule A (payload files, path internal/cldr/<domain>/data.go): only const
//     declarations are allowed. Zero func, zero var, zero non-const expression,
//     zero import. This is the representation invariant the migration
//     converges on.
//   - Rule B (all other generated files): no CallExpr, IndexExpr, or
//     IndexListExpr may appear inside any composite literal. This kills the
//     compile bomb shape (makeUnitPatternKey(localeIndex["af"], …)) at the
//     data-literal level.
//
// The gate asserts the absolute shape; it never measures a count. The legacy
// shrink-only exemption table is now empty: the root literal renderers it
// carried (list_patterns.go, locales.go) retired with the root internal/cldr
// package, so every generated file satisfies its invariant directly.

// genHeaderPrefixes identify a generated file by its leading comment. Both
// generators in this repo (tools/gen-cldr and tools/gen-plural-rules) emit a
// "Code generated by" header; the data-shape gate trusts the same-commit
// generator that produced the file.
var genHeaderPrefixes = []string{
	"// Code generated by tools/gen-cldr",
	"// Code generated by tools/gen-plural-rules",
}

// shapeRule names which invariant an exemption suspends for one file.
type shapeRule int

const (
	ruleA shapeRule = iota // payload data.go must be const-only
	ruleB                  // composite literals must hold no call or index expressions
)

func (r shapeRule) String() string {
	switch r {
	case ruleA:
		return "A (const-only payload)"
	case ruleB:
		return "B (no call or index expressions in composite literals)"
	default:
		return "unknown"
	}
}

// shapeExemption is one legacy generated file that still violates a shape rule.
type shapeExemption struct {
	// rel is the repo-root-relative slash path of the violating generated file.
	rel string
	// rule is the single invariant this file is temporarily allowed to break.
	rule shapeRule
	// migratesTo records the target domain package that retires this file; the
	// exemption is deleted when the file moves there in const-only form.
	migratesTo string
}

// legacyShapeExemptions is the shrink-only exemption table. It is empty: the
// migration is complete, so no generated file is exempt. The gate fails if an
// exempted file is missing or no longer violates its rule, so the table cannot
// mask a completed migration or accumulate stale rows.
var legacyShapeExemptions = []shapeExemption{}

func TestGeneratedDataShape(t *testing.T) {
	t.Parallel()

	root := filepath.Clean("../../..")
	files := generatedGoFiles(t, root)
	if len(files) == 0 {
		t.Fatal("no generated Go files discovered; data-shape gate would be vacuous")
	}

	// exempted maps a relative slash path to the rule it is allowed to violate.
	exempted := make(map[string]shapeRule, len(legacyShapeExemptions))
	for _, e := range legacyShapeExemptions {
		if prev, dup := exempted[e.rel]; dup {
			t.Fatalf("exemption table lists %s twice (rules %s and %s)", e.rel, prev, e.rule)
		}
		exempted[e.rel] = e.rule
	}

	// Track which exemptions actually fire so the table can only shrink.
	used := make(map[string]bool, len(legacyShapeExemptions))

	for _, path := range files {
		rel := filepath.ToSlash(mustRel(t, root, path))
		t.Run(rel, func(t *testing.T) {
			t.Parallel()
			violation := generatedFileShapeViolation(t, path, rel)
			exemptRule, isExempt := exempted[rel]
			switch {
			case violation == nil:
				// Clean file: an exemption here would be stale.
				return
			case isExempt && *violation == exemptRule:
				// Known legacy violation, explicitly carried.
				return
			default:
				t.Errorf("rule %s violated in generated file %s", violation, rel)
			}
		})
		if v := generatedFileShapeViolation(t, path, rel); v != nil {
			if r, ok := exempted[rel]; ok && r == *v {
				used[rel] = true
			}
		}
	}

	// Shrink-only enforcement: every exemption must name an existing file that
	// still violates its declared rule. A missing or already-fixed file forces
	// its row out of the table.
	for _, e := range legacyShapeExemptions {
		if !used[e.rel] {
			if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(e.rel))); err != nil {
				t.Errorf("exemption for %s no longer exists on disk; remove its row (migrated to %s)", e.rel, e.migratesTo)
				continue
			}
			t.Errorf("exemption for %s no longer violates rule %s; remove its row (migrated to %s)", e.rel, e.rule, e.migratesTo)
		}
	}
}

// generatedFileShapeViolation parses one generated file and returns the rule it
// violates, or nil if the file satisfies its applicable invariant. Rule A
// applies to payload files (internal/cldr/<domain>/data.go); Rule B applies to
// every other generated file.
func generatedFileShapeViolation(t *testing.T, path, rel string) *shapeRule {
	t.Helper()

	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
	if err != nil {
		t.Fatalf("parse %s: %v", rel, err)
	}

	if isPayloadDataFile(rel) {
		if !isConstOnly(f) {
			r := ruleA
			return &r
		}
		return nil
	}

	if compositeLiteralHasCallOrIndex(f) {
		r := ruleB
		return &r
	}
	return nil
}

// isPayloadDataFile reports whether rel is a domain payload file of the form
// internal/cldr/<domain>/data.go (exactly one path segment between the cldr
// root and the data.go leaf).
func isPayloadDataFile(rel string) bool {
	const prefix = "internal/cldr/"
	const leaf = "/data.go"
	if !strings.HasPrefix(rel, prefix) || !strings.HasSuffix(rel, leaf) {
		return false
	}
	domain := rel[len(prefix) : len(rel)-len(leaf)]
	return domain != "" && !strings.Contains(domain, "/")
}

// isConstOnly reports whether the file contains only const GenDecls: no
// imports, no var, no type, no func — the const-only representation shape.
func isConstOnly(f *ast.File) bool {
	for _, decl := range f.Decls {
		gen, ok := decl.(*ast.GenDecl)
		if !ok {
			return false // FuncDecl
		}
		if gen.Tok != token.CONST {
			return false // import, var, or type
		}
	}
	return true
}

// compositeLiteralHasCallOrIndex reports whether any composite literal in the
// file contains a CallExpr, IndexExpr, or IndexListExpr (generic
// instantiation) — the compile-bomb literal shape.
func compositeLiteralHasCallOrIndex(f *ast.File) bool {
	found := false
	ast.Inspect(f, func(n ast.Node) bool {
		if found {
			return false
		}
		cl, ok := n.(*ast.CompositeLit)
		if !ok {
			return true
		}
		ast.Inspect(cl, func(m ast.Node) bool {
			switch m.(type) {
			case *ast.CallExpr, *ast.IndexExpr, *ast.IndexListExpr:
				found = true
				return false
			}
			return true
		})
		return true
	})
	return found
}

// generatedGoFiles walks the repository from root and returns every generated
// Go source file, identified by the generator header comment. Dot-directories
// (.git, .tmp, .references) and the tools/ generator module trees are skipped:
// the gate governs committed generated data, not transient build artifacts or
// the generators themselves.
func generatedGoFiles(t *testing.T, root string) []string {
	t.Helper()

	var files []string
	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			name := d.Name()
			if path != root && (strings.HasPrefix(name, ".") || name == "tools") {
				return filepath.SkipDir
			}
			return nil
		}
		name := d.Name()
		if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
			return nil
		}
		if isGeneratedByHeader(t, path) {
			files = append(files, path)
		}
		return nil
	})
	if err != nil {
		t.Fatalf("walk repository: %v", err)
	}
	slices.Sort(files)
	return files
}

// isGeneratedByHeader reports whether the file carries a recognized generator
// header comment near its top. It parses comments only, avoiding a full parse
// for the discovery pass.
func isGeneratedByHeader(t *testing.T, path string) bool {
	t.Helper()

	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, path, nil, parser.PackageClauseOnly|parser.ParseComments)
	if err != nil {
		t.Fatalf("parse comments %s: %v", path, err)
	}
	for _, group := range f.Comments {
		for _, c := range group.List {
			for _, prefix := range genHeaderPrefixes {
				if strings.HasPrefix(c.Text, prefix) {
					return true
				}
			}
		}
	}
	return false
}

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

	rel, err := filepath.Rel(root, path)
	if err != nil {
		t.Fatalf("relative path for %s: %v", path, err)
	}
	return rel
}
