// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2026-present Datadog, Inc.

package analysis

import (
	"fmt"
	"go/parser"
	"go/token"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// allowedSymbolsConfig configures a single run of the allowed-symbols check.
type allowedSymbolsConfig struct {
	// Symbols is the allowlist to enforce (e.g. builtinAllowedSymbols).
	Symbols []string
	// TargetDir is the directory to scan, relative to the repo root.
	TargetDir string
	// CollectFiles walks TargetDir and returns the absolute paths of Go files
	// to check. It receives the absolute path to TargetDir.
	CollectFiles func(dir string) ([]string, error)
	// ExemptImport returns true for import paths that are auto-allowed and
	// should not be checked against the allowlist.
	ExemptImport func(importPath string) bool
	// ListName is used in error messages (e.g. "builtinAllowedSymbols").
	ListName string
	// MinFiles is the minimum number of files expected (sanity check).
	MinFiles int
	// RepoRootOverride, if set, is used instead of auto-detecting the repo
	// root from os.Getwd(). Used by verification tests that operate on a
	// temp copy.
	RepoRootOverride string
	// Errors, if non-nil, collects error messages instead of calling t.Errorf.
	// Used by verification tests to inspect specific errors.
	Errors *[]string
}

// checkAllowedSymbols enforces symbol-level import restrictions on a set of
// Go source files. It verifies that every imported symbol is in the allowlist,
// that no permanently banned packages are imported, and that every symbol in
// the allowlist is actually used.
//
// The core checking logic is provided by the analyzer helpers in analyzer.go
// (checkFileImports, checkFileSelectors, reportUnused) — this function handles
// file discovery, AST parsing, and test-framework integration.
func checkAllowedSymbols(t *testing.T, cfg allowedSymbolsConfig) {
	t.Helper()

	// Build lookup sets from the allowlist.
	allowedSyms, allowedPkgs := buildAllowlistSets(cfg.Symbols)
	usedSymbols := make(map[string]bool, len(cfg.Symbols))

	// Validate allowlist entries are well-formed.
	for _, entry := range cfg.Symbols {
		dot := strings.LastIndexByte(entry, '.')
		if dot <= 0 {
			t.Fatalf("malformed allowlist entry (no dot): %q", entry)
		}
	}

	// reportErr collects errors into cfg.Errors when set, otherwise calls t.Errorf.
	reportErr := func(format string, args ...any) {
		msg := fmt.Sprintf(format, args...)
		if cfg.Errors != nil {
			*cfg.Errors = append(*cfg.Errors, msg)
		} else {
			t.Errorf("%s", msg)
		}
	}

	// Determine the repo root.
	var root string
	if cfg.RepoRootOverride != "" {
		root = cfg.RepoRootOverride
	} else {
		// This package lives in analysis/, so the repo root is one level up.
		dir, err := os.Getwd()
		if err != nil {
			t.Fatal(err)
		}
		root = filepath.Dir(dir)
	}
	targetDir := filepath.Join(root, cfg.TargetDir)

	goFiles, err := cfg.CollectFiles(targetDir)
	if err != nil {
		t.Fatal(err)
	}

	fset := token.NewFileSet()
	checked := 0
	for _, path := range goFiles {
		rel, _ := filepath.Rel(targetDir, path)
		checked++

		f, err := parser.ParseFile(fset, path, nil, 0)
		if err != nil {
			reportErr("%s: parse error: %v", rel, err)
			continue
		}

		// Use analyzer helpers for import checking and selector walking.
		reporter := fileLineReporter(fset, rel, reportErr)
		localToPath := checkFileImports(f, allowedPkgs, cfg.ExemptImport, reporter)
		checkFileSelectors(f, localToPath, allowedSyms, usedSymbols, reporter)

		// Structural rules — applied to every checked file.
		checkFileScannerBuffer(f, reporter)
		checkFileOpenFileClose(f, reporter)
	}

	if checked < cfg.MinFiles {
		t.Fatalf("expected at least %d files in %s, found %d", cfg.MinFiles, cfg.TargetDir, checked)
	}

	// Verify every symbol in the allowlist is actually used by at least one
	// file. Unused entries should be removed to keep the allowlist minimal.
	reportUnused(cfg.Symbols, usedSymbols, func(entry string) {
		reportErr("allowlist symbol %q is not used by any file in %s — remove it from %s",
			entry, cfg.TargetDir, cfg.ListName)
	})
}

// perBuiltinConfig holds the configuration for checkPerBuiltinAllowedSymbols.
type perBuiltinConfig struct {
	// CommonSymbols is the global ceiling list (e.g. builtinAllowedSymbols).
	CommonSymbols []string
	// PerCommandSymbols maps each builtin name to its per-command allowlist.
	PerCommandSymbols map[string][]string
	// TargetDir is the directory containing builtin subdirectories.
	TargetDir string
	// ExemptImport returns true for import paths that are auto-allowed.
	ExemptImport func(importPath string) bool
	// SkipDirs is the set of subdirectory names to skip entirely.
	SkipDirs map[string]bool
	// RepoRootOverride, if set, overrides auto-detection of the repo root.
	RepoRootOverride string
	// Errors, if non-nil, collects error messages instead of calling t.Errorf.
	Errors *[]string
}

// checkPerBuiltinAllowedSymbols enforces two-layer symbol restrictions:
//  1. Every symbol in each per-command list must be in the common list.
//  2. Each builtin subdirectory's files may only use symbols from its per-command list.
//  3. Every symbol in a per-command list must be used by at least one file in that builtin.
//  4. Every symbol in the common list must appear in at least one per-command list.
//  5. Every builtin subdirectory must have an entry in the per-command map.
func checkPerBuiltinAllowedSymbols(t *testing.T, cfg perBuiltinConfig) {
	t.Helper()

	reportErr := func(format string, args ...any) {
		msg := fmt.Sprintf(format, args...)
		if cfg.Errors != nil {
			*cfg.Errors = append(*cfg.Errors, msg)
		} else {
			t.Errorf("%s", msg)
		}
	}

	// Build common set for quick lookup.
	commonSet := make(map[string]bool, len(cfg.CommonSymbols))
	for _, s := range cfg.CommonSymbols {
		commonSet[s] = true
	}

	// Track which common symbols appear in at least one per-command list.
	commonUsed := make(map[string]bool)

	// 1. Validate per-command lists are subsets of the common list.
	for cmd, symbols := range cfg.PerCommandSymbols {
		for _, s := range symbols {
			if !commonSet[s] {
				reportErr("builtinPerCommandSymbols[%q]: symbol %q is not in builtinAllowedSymbols", cmd, s)
			}
			commonUsed[s] = true
		}
	}

	// Determine repo root.
	var root string
	if cfg.RepoRootOverride != "" {
		root = cfg.RepoRootOverride
	} else {
		dir, err := os.Getwd()
		if err != nil {
			t.Fatal(err)
		}
		root = filepath.Dir(dir)
	}
	targetDir := filepath.Join(root, cfg.TargetDir)

	// 2 & 3. Per-builtin file check + unused check.
	for cmd, symbols := range cfg.PerCommandSymbols {
		cmdDir := filepath.Join(targetDir, cmd)
		info, err := os.Stat(cmdDir)
		if err != nil || !info.IsDir() {
			reportErr("builtinPerCommandSymbols[%q]: directory %s does not exist", cmd, cmd)
			continue
		}

		goFiles, err := collectFlatGoFiles(cmdDir)
		if err != nil {
			t.Fatalf("builtinPerCommandSymbols[%q]: %v", cmd, err)
		}

		// Run checkAllowedSymbols scoped to this builtin's per-command list.
		var cmdErrs []string
		checkAllowedSymbols(t, allowedSymbolsConfig{
			Symbols:   symbols,
			TargetDir: filepath.Join(cfg.TargetDir, cmd),
			CollectFiles: func(dir string) ([]string, error) {
				return goFiles, nil
			},
			ExemptImport:     cfg.ExemptImport,
			ListName:         fmt.Sprintf("builtinPerCommandSymbols[%q]", cmd),
			MinFiles:         0,
			RepoRootOverride: root,
			Errors:           &cmdErrs,
		})

		for _, e := range cmdErrs {
			reportErr("%s", e)
		}
	}

	// 4. Common list coverage: every common symbol must appear in at least one per-command list.
	for _, s := range cfg.CommonSymbols {
		if !commonUsed[s] {
			reportErr("builtinAllowedSymbols symbol %q is not in any builtin's per-command list — remove it from builtinAllowedSymbols or add it to the appropriate builtin", s)
		}
	}

	// 5. Missing builtin check: every builtin subdirectory must have an entry.
	entries, err := os.ReadDir(targetDir)
	if err != nil {
		t.Fatal(err)
	}
	for _, e := range entries {
		if !e.IsDir() || cfg.SkipDirs[e.Name()] {
			continue
		}
		if _, ok := cfg.PerCommandSymbols[e.Name()]; !ok {
			reportErr("builtin subdirectory %q has no entry in builtinPerCommandSymbols", e.Name())
		}
	}
}

// collectSubdirGoFiles walks a directory tree and returns all non-test .go
// files that are inside subdirectories (not at the top level). Optionally
// skips directories by name.
func collectSubdirGoFiles(dir string, skipDirs map[string]bool, skipTopLevel func(rel string) bool) ([]string, error) {
	var files []string
	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			if skipDirs[info.Name()] {
				return filepath.SkipDir
			}
			return nil
		}
		if !strings.HasSuffix(info.Name(), ".go") || strings.HasSuffix(info.Name(), "_test.go") {
			return nil
		}
		rel, _ := filepath.Rel(dir, path)
		if skipTopLevel != nil && skipTopLevel(rel) {
			return nil
		}
		// Only check files inside subdirectories.
		if !strings.Contains(rel, string(filepath.Separator)) {
			return nil
		}
		files = append(files, path)
		return nil
	})
	return files, err
}

// collectFlatGoFiles returns all non-test .go files directly in dir (not
// in subdirectories).
func collectFlatGoFiles(dir string) ([]string, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	var files []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		if !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") {
			continue
		}
		files = append(files, filepath.Join(dir, e.Name()))
	}
	return files, nil
}

// fileLineReporter returns a report function suitable for use with
// checkFileImports and checkFileSelectors in the test harness. It translates
// token.Pos into file:line strings using fset and forwards messages via errorf.
// When pos is token.NoPos, only the format+args message is emitted without a
// location prefix.
func fileLineReporter(fset *token.FileSet, relPath string, errorf func(string, ...any)) func(token.Pos, string, ...any) {
	return func(pos token.Pos, format string, args ...any) {
		msg := fmt.Sprintf(format, args...)
		if pos != token.NoPos && fset != nil {
			p := fset.Position(pos)
			errorf("%s:%d: %s", relPath, p.Line, msg)
		} else {
			errorf("%s: %s", relPath, msg)
		}
	}
}
