package main

import (
	"bufio"
	"os"
	"sort"
	"strings"
)

// ForbiddenEdge is one declarative rule: `From` may not depend on any module in
// `To` (or matching `ToPrefixes`) via the non-test first-party require closure.
type ForbiddenEdge struct {
	From       string   // offending source module path
	To         []string // explicit forbidden target module paths
	ToPrefixes []string // forbidden target prefixes (e.g. engine modules)
	Why        string   // human-readable rationale
}

// contractSinkRule is special-cased: contract must import NO first-party sibling.

// forbiddenEdges is the declarative policy table. Each rule is checked against
// the first-party require edges of `From`; any matching target is a violation.
// Today's reality is captured in forbidden_edge_baseline.txt so the gate fails
// only on regressions.
var forbiddenEdges = []ForbiddenEdge{
	{
		From: "github.com/pingcap-inc/nexuscrm-api-host",
		To: []string{
			"github.com/nexuscrm/backend",
			"github.com/pingcap-inc/nexuscrm-controlplane",
		},
		ToPrefixes: []string{}, // engine modules handled below via engineModules
		Why:        "api-host must not depend (non-test) on backend / controlplane / engine modules",
	},
	{
		From:       "github.com/pingcap-inc/nexuscrm-controlplane",
		To:         []string{"github.com/nexuscrm/backend"},
		ToPrefixes: []string{},
		Why:        "controlplane must not depend on backend in the non-test module closure",
	},
}

// engineModules are the generic engine modules api-host must not pull in.
var engineModules = []string{
	"github.com/nexuscrm/analytics",
	"github.com/nexuscrm/audit",
	"github.com/nexuscrm/automation",
	"github.com/nexuscrm/db",
	"github.com/nexuscrm/events",
	"github.com/nexuscrm/files",
	"github.com/nexuscrm/metadata",
	"github.com/nexuscrm/outbound",
	"github.com/nexuscrm/permissions",
	"github.com/nexuscrm/persistence",
	"github.com/nexuscrm/records",
	"github.com/nexuscrm/storage",
	"github.com/nexuscrm/worker",
}

// EdgeViolation is one concrete forbidden-edge hit: From requires To.
type EdgeViolation struct {
	From string
	To   string
	Rule string // human-readable rule label
}

// Key is the baseline identity of a violation.
func (v EdgeViolation) Key() string { return v.From + " -> " + v.To }

const externalPinOwner = "github.com/nexuscrm/backend"

var (
	synchronizedExternalModules = []string{
		"github.com/pingcap-inc/nexuscrm-api-host",
		"github.com/pingcap-inc/nexuscrm-connectors",
		"github.com/pingcap-inc/nexuscrm-contract",
		"github.com/pingcap-inc/nexuscrm-controlplane",
	}
	externalPinConsumers = []string{
		"github.com/nexuscrm/backend/distributions",
	}
)

// ExternalPinMismatch is a published L0 module whose version differs between
// backend/go.mod (the owner) and another release-shaped host consumer. A
// consumer that does not require the module is valid; one that does must use
// the owner pin. Engine modules keep their own independently compiled contract
// pins and are checked by the standalone workspace graph seal instead.
type ExternalPinMismatch struct {
	Module   string
	Consumer string
	Expected string
	Actual   string
}

func (m ExternalPinMismatch) String() string {
	if m.Consumer == externalPinOwner {
		return m.Consumer + " is missing required external module " + m.Module
	}
	return m.Consumer + " requires " + m.Module + " " + m.Actual +
		", but " + externalPinOwner + " pins " + m.Expected
}

// evaluateExternalPinMismatches is deliberately network-free. It checks the
// checked-in go.mod facts directly so local and review-time boundarygate runs
// catch sibling-consumer drift before the trusted exact-main module download.
func evaluateExternalPinMismatches(modules map[string]*Module) []ExternalPinMismatch {
	owner := modules[externalPinOwner]
	if owner == nil || owner.Missing {
		return []ExternalPinMismatch{{
			Module:   "(owner module)",
			Consumer: externalPinOwner,
		}}
	}

	var out []ExternalPinMismatch
	for _, modulePath := range synchronizedExternalModules {
		expected := owner.RequireVersions[modulePath]
		if expected == "" {
			out = append(out, ExternalPinMismatch{
				Module:   modulePath,
				Consumer: externalPinOwner,
			})
			continue
		}
		for _, consumerPath := range externalPinConsumers {
			consumer := modules[consumerPath]
			if consumer == nil || consumer.Missing {
				continue
			}
			actual, required := consumer.RequireVersions[modulePath]
			if required && actual != expected {
				out = append(out, ExternalPinMismatch{
					Module:   modulePath,
					Consumer: consumerPath,
					Expected: expected,
					Actual:   actual,
				})
			}
		}
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Module != out[j].Module {
			return out[i].Module < out[j].Module
		}
		return out[i].Consumer < out[j].Consumer
	})
	return out
}

// evaluateForbiddenEdges walks the policy table against the actual first-party
// require edges and returns every concrete violation found today.
func evaluateForbiddenEdges(modules map[string]*Module) []EdgeViolation {
	var out []EdgeViolation

	// Rule: contract is the sink — it must require NO first-party sibling.
	if c := modules[contractModulePath]; c != nil && !c.Missing {
		for _, dep := range c.firstPartyEdges() {
			out = append(out, EdgeViolation{
				From: contractModulePath, To: dep,
				Rule: "contract must import no first-party nexuscrm sibling (sink)",
			})
		}
	}

	for _, rule := range forbiddenEdges {
		from := modules[rule.From]
		if from == nil || from.Missing {
			continue
		}
		forbidden := map[string]bool{}
		for _, t := range rule.To {
			forbidden[t] = true
		}
		// api-host also forbids the engine modules.
		if rule.From == "github.com/pingcap-inc/nexuscrm-api-host" {
			for _, e := range engineModules {
				forbidden[e] = true
			}
		}
		edges := from.firstPartyEdges()
		for _, dep := range edges {
			matched := forbidden[dep]
			if !matched {
				for _, pre := range rule.ToPrefixes {
					if strings.HasPrefix(dep, pre) {
						matched = true
						break
					}
				}
			}
			if matched {
				out = append(out, EdgeViolation{From: rule.From, To: dep, Rule: rule.Why})
			}
		}
	}

	sort.Slice(out, func(i, j int) bool {
		if out[i].From != out[j].From {
			return out[i].From < out[j].From
		}
		return out[i].To < out[j].To
	})
	return out
}

// loadBaseline reads a baseline file: one entry per non-blank, non-comment line.
// Missing file -> empty set (treated as "nothing baselined yet").
func loadBaseline(path string) (map[string]bool, error) {
	set := map[string]bool{}
	f, err := os.Open(path)
	if err != nil {
		if os.IsNotExist(err) {
			return set, nil
		}
		return nil, err
	}
	defer f.Close()
	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		set[line] = true
	}
	return set, sc.Err()
}

// loadDialectBaseline reads the L0 dialect-token baseline. Each non-comment line
// is a repo-relative file path, optionally followed by whitespace and a
// comma-separated list of waived tokens:
//
//	nexuscrm-records/foo.go\tsobject,soql   # waives ONLY sobject+soql in foo.go
//	nexuscrm-records/bar.go                 # legacy: waives the whole file
//
// Token-scoped entries make the gate fail when a NEW dialect token appears in an
// already-frozen file; bare-path entries are the legacy whole-file freeze.
func loadDialectBaseline(path string) (map[string]map[string]bool, error) {
	out := map[string]map[string]bool{}
	f, err := os.Open(path)
	if err != nil {
		if os.IsNotExist(err) {
			return out, nil
		}
		return nil, err
	}
	defer f.Close()
	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		fields := strings.Fields(line)
		file := fields[0]
		if len(fields) == 1 {
			out[file] = nil // legacy whole-file waiver
			continue
		}
		toks := map[string]bool{}
		for _, field := range fields[1:] {
			for _, t := range strings.Split(field, ",") {
				if t = strings.ToLower(strings.TrimSpace(t)); t != "" {
					toks[t] = true
				}
			}
		}
		out[file] = toks
	}
	return out, sc.Err()
}

// dialectNewTokens returns the tokens in h not waived by the baseline. A file
// absent from the baseline yields all its tokens; a nil token set (legacy
// whole-file waiver) yields none; a token set yields only the unlisted tokens.
func dialectNewTokens(h DialectHit, baseline map[string]map[string]bool) []string {
	waived, ok := baseline[h.File]
	if !ok {
		return h.Tokens
	}
	if waived == nil {
		return nil
	}
	var out []string
	for _, t := range h.Tokens {
		if !waived[t] {
			out = append(out, t)
		}
	}
	return out
}

// dialectBaselineLine renders a token-scoped baseline line for h.
func dialectBaselineLine(h DialectHit) string {
	toks := append([]string(nil), h.Tokens...)
	sort.Strings(toks)
	return h.File + "\t" + strings.Join(toks, ",")
}

// writeLines writes a sorted, header-commented baseline/allowlist file.
func writeLines(path, header string, lines []string) error {
	sorted := append([]string(nil), lines...)
	sort.Strings(sorted)
	var b strings.Builder
	if header != "" {
		b.WriteString(header)
		if !strings.HasSuffix(header, "\n") {
			b.WriteString("\n")
		}
	}
	for _, l := range sorted {
		b.WriteString(l)
		b.WriteString("\n")
	}
	return os.WriteFile(path, []byte(b.String()), 0o644)
}
