package main

import (
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
)

// contractModulePath is the import sink that must stay neutral.
const contractModulePath = "github.com/pingcap-inc/nexuscrm-contract"

// l0Modules is the set of first-party module paths whose source is scanned for
// product-dialect tokens. These are the generic engines per AGENTS.md (metadata
// storage, schema/runtime, records, automation, permissions, RLS, seeding, …).
// Explicitly EXCLUDED: contract (neutral sink), apihost (dialect host),
// backend & controlplane (the CRM app + its control
// plane), distributions (build wiring), conformance tests, and the app-packs
// dialect modules. Those legitimately speak a product dialect.
var l0Modules = map[string]bool{
	"github.com/nexuscrm/analytics":   true,
	"github.com/nexuscrm/audit":       true,
	"github.com/nexuscrm/auth":        true,
	"github.com/nexuscrm/automation":  true,
	"github.com/nexuscrm/db":          true,
	"github.com/nexuscrm/events":      true,
	"github.com/nexuscrm/files":       true,
	"github.com/nexuscrm/kernel":      true,
	"github.com/nexuscrm/metadata":    true,
	"github.com/nexuscrm/outbound":    true,
	"github.com/nexuscrm/permissions": true,
	"github.com/nexuscrm/persistence": true,
	"github.com/nexuscrm/records":     true,
	"github.com/nexuscrm/shared":      true,
	"github.com/nexuscrm/storage":     true,
	"github.com/nexuscrm/worker":      true,
}

// dialectTokens are case-insensitive product-dialect markers. A file "hits" if
// any token appears anywhere in its text, except sobject, whose CamelCase
// spelling would otherwise misclassify neutral words such as "crossObject".
// The intent is to FREEZE the known dialect leak so it cannot grow.
var dialectTokens = []string{
	"salesforce", "soql", "sosl", "sobject", "hubspot", "opportunity", "tooling",
}

// reSQLHTTPImport matches an import of database/sql or net/http.
var reSQLHTTPImport = regexp.MustCompile(`"(database/sql|net/http)"`)

// isGoSource reports whether a path is a Go source file we should scan.
func isGoSource(path string) bool {
	return strings.HasSuffix(path, ".go")
}

// isTestFile reports whether a Go file is a test file.
func isTestFile(path string) bool {
	return strings.HasSuffix(path, "_test.go")
}

// walkGoFiles yields every *.go file under dir, excluding nested module dirs and
// the usual junk directories.
func walkGoFiles(dir string, nested []string, fn func(path string)) {
	_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return nil
		}
		if d.IsDir() {
			base := d.Name()
			if base == "node_modules" || base == "vendor" || base == ".git" {
				return filepath.SkipDir
			}
			for _, n := range nested {
				if path == n {
					return filepath.SkipDir
				}
			}
			return nil
		}
		if isGoSource(path) {
			fn(path)
		}
		return nil
	})
}

// nestedDirsOf returns nested module directories under m so a parent module's
// scan doesn't bleed into a child module.
func nestedDirsOf(m *Module, modules map[string]*Module) []string {
	var nested []string
	for _, other := range modules {
		if other == m || other.Missing || other.Dir == "" {
			continue
		}
		if strings.HasPrefix(other.Dir, m.Dir+string(os.PathSeparator)) {
			nested = append(nested, other.Dir)
		}
	}
	return nested
}

// scanContractRuntimeLeak returns repo-relative paths of contract package files
// that import database/sql or net/http (non-test). Sorted, deduplicated.
func scanContractRuntimeLeak(root string, modules map[string]*Module) []string {
	m := modules[contractModulePath]
	if m == nil || m.Missing {
		return nil
	}
	nested := nestedDirsOf(m, modules)
	var hits []string
	walkGoFiles(m.Dir, nested, func(path string) {
		if isTestFile(path) {
			return
		}
		data, err := os.ReadFile(path)
		if err != nil {
			return
		}
		if reSQLHTTPImport.MatchString(string(data)) {
			rel, _ := filepath.Rel(root, path)
			hits = append(hits, filepath.ToSlash(rel))
		}
	})
	sort.Strings(hits)
	return dedup(hits)
}

// DialectHit is one L0 file that matched at least one dialect token.
type DialectHit struct {
	File   string   // repo-relative, slash-separated
	Module string   // owning L0 module path
	Tokens []string // sorted distinct matched tokens
}

// scanL0Dialect scans all L0 module source (non-test) for dialect tokens and
// returns one DialectHit per offending file. Deterministic ordering by file.
func scanL0Dialect(root string, modules map[string]*Module) []DialectHit {
	var hits []DialectHit
	// Stable module iteration.
	var modPaths []string
	for p := range modules {
		if l0Modules[p] {
			modPaths = append(modPaths, p)
		}
	}
	sort.Strings(modPaths)

	for _, p := range modPaths {
		m := modules[p]
		if m == nil || m.Missing {
			continue
		}
		nested := nestedDirsOf(m, modules)
		walkGoFiles(m.Dir, nested, func(path string) {
			if isTestFile(path) {
				return
			}
			data, err := os.ReadFile(path)
			if err != nil {
				return
			}
			source := string(data)
			var matched []string
			seen := map[string]bool{}
			for _, tok := range dialectTokens {
				if containsDialectToken(source, tok) && !seen[tok] {
					matched = append(matched, tok)
					seen[tok] = true
				}
			}
			if len(matched) > 0 {
				sort.Strings(matched)
				rel, _ := filepath.Rel(root, path)
				hits = append(hits, DialectHit{
					File:   filepath.ToSlash(rel),
					Module: p,
					Tokens: matched,
				})
			}
		})
	}
	sort.Slice(hits, func(i, j int) bool { return hits[i].File < hits[j].File })
	return hits
}

func containsDialectToken(source, token string) bool {
	if token == "" || len(source) < len(token) {
		return false
	}
	if token != "sobject" {
		return strings.Contains(strings.ToLower(source), token)
	}
	for start := 0; start+len(token) <= len(source); start++ {
		end := start + len(token)
		if !strings.EqualFold(source[start:end], token) {
			continue
		}
		if start > 0 && isASCIIIdentifierByte(source[start-1]) &&
			!(isASCIILower(source[start-1]) && isASCIIUpper(source[start])) {
			continue
		}
		if end < len(source) && isASCIIIdentifierByte(source[end]) &&
			!(isASCIILower(source[end-1]) && isASCIIUpper(source[end])) {
			continue
		}
		return true
	}
	return false
}

func isASCIIIdentifierByte(b byte) bool {
	return b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z'
}

func isASCIILower(b byte) bool { return b >= 'a' && b <= 'z' }

func isASCIIUpper(b byte) bool { return b >= 'A' && b <= 'Z' }

func dedup(in []string) []string {
	if len(in) == 0 {
		return in
	}
	out := in[:0:0]
	var last string
	for i, s := range in {
		if i == 0 || s != last {
			out = append(out, s)
		}
		last = s
	}
	return out
}
