package main

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

// Module is one discovered go.mod and its first-party dependency edges.
type Module struct {
	Path            string            // module path, e.g. github.com/nexuscrm/records
	Dir             string            // absolute directory containing go.mod
	RelDir          string            // repo-relative directory
	GoModRel        string            // repo-relative go.mod path
	Requires        []string          // first-party module paths required (require directives)
	RequireVersions map[string]string // exact versions from require directives
	Replaces        []string          // first-party module paths the replace targets resolve to (best-effort)
	Missing         bool              // true when a replace points outside the checked-out tree
	LOC             int               // total non-blank Go source lines under Dir (excluding nested modules)
}

// firstParty reports whether a module path belongs to the nexuscrm first-party
// graph (the only edges the gate reasons about).
func firstParty(modPath string) bool {
	return strings.HasPrefix(modPath, "github.com/nexuscrm/") ||
		strings.HasPrefix(modPath, "github.com/pingcap-inc/nexuscrm-")
}

var (
	reModuleDecl = regexp.MustCompile(`(?m)^\s*module\s+(\S+)`)
	// require "path version"  (single-line form)
	reRequireLine = regexp.MustCompile(`^\s*require\s+(\S+)\s+(\S+)`)
	// inside a require ( ... ) block: "path version // comment"
	reReqBlockItem = regexp.MustCompile(`^\s*(\S+)\s+(\S+)`)
	// replace old [version] => new [version]
	reReplaceLine = regexp.MustCompile(`^\s*replace\s+(\S+)(?:\s+\S+)?\s+=>\s+(\S+)(?:\s+\S+)?`)
	reReplaceItem = regexp.MustCompile(`^\s*(\S+)(?:\s+\S+)?\s+=>\s+(\S+)(?:\s+\S+)?`)
)

// parseModulePath returns the module path declared in a go.mod file body.
func parseModulePath(body string) string {
	m := reModuleDecl.FindStringSubmatch(body)
	if m == nil {
		return ""
	}
	return m[1]
}

// goModInfo is the raw parse of a single go.mod: requires plus replace targets.
type goModInfo struct {
	path            string
	requires        []string          // all require paths (first-party filtered later)
	requireVersions map[string]string // require path -> exact declared version
	replaces        map[string]string // old module path -> replacement target (path or relative dir)
}

// parseGoMod is a small hand-rolled, stdlib-only go.mod parser. It captures the
// module path, every require directive (single-line and block), and every
// replace directive (single-line and block). It does NOT attempt full fidelity;
// it only needs require/replace which are all the gate reasons about.
func parseGoMod(body string) goModInfo {
	info := goModInfo{
		requireVersions: map[string]string{},
		replaces:        map[string]string{},
	}
	info.path = parseModulePath(body)

	lines := strings.Split(body, "\n")
	inRequire := false
	inReplace := false
	for _, raw := range lines {
		line := stripLineComment(raw)
		trimmed := strings.TrimSpace(line)
		if trimmed == "" {
			continue
		}

		// Block terminators.
		if inRequire && trimmed == ")" {
			inRequire = false
			continue
		}
		if inReplace && trimmed == ")" {
			inReplace = false
			continue
		}

		// Block bodies.
		if inRequire {
			if m := reReqBlockItem.FindStringSubmatch(line); m != nil {
				info.requires = append(info.requires, m[1])
				info.requireVersions[m[1]] = m[2]
			}
			continue
		}
		if inReplace {
			if m := reReplaceItem.FindStringSubmatch(line); m != nil {
				info.replaces[m[1]] = m[2]
			}
			continue
		}

		// Block openers.
		if strings.HasPrefix(trimmed, "require") && strings.Contains(trimmed, "(") {
			inRequire = true
			continue
		}
		if strings.HasPrefix(trimmed, "replace") && strings.Contains(trimmed, "(") {
			inReplace = true
			continue
		}

		// Single-line directives.
		if m := reReplaceLine.FindStringSubmatch(line); m != nil {
			info.replaces[m[1]] = m[2]
			continue
		}
		if m := reRequireLine.FindStringSubmatch(line); m != nil {
			info.requires = append(info.requires, m[1])
			info.requireVersions[m[1]] = m[2]
			continue
		}
	}
	return info
}

// stripLineComment removes a trailing // comment while keeping the directive.
func stripLineComment(line string) string {
	if i := strings.Index(line, "//"); i >= 0 {
		return line[:i]
	}
	return line
}

// discoverModules walks the repo from root, parses every go.mod, and builds the
// first-party module set keyed by module path. Replace targets that resolve to
// directories outside the checked-out tree (e.g. the app-packs dialects) are
// recorded as Missing placeholders so the graph stays consistent without failing.
func discoverModules(root string) (map[string]*Module, []string, error) {
	// Find every go.mod (skip vendor / node_modules / the tool's own dir is fine to include).
	var goMods []string
	err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return nil // tolerate unreadable dirs
		}
		if d.IsDir() {
			name := d.Name()
			if name == "node_modules" || name == "vendor" || name == ".git" {
				return filepath.SkipDir
			}
			return nil
		}
		if d.Name() == "go.mod" {
			goMods = append(goMods, path)
		}
		return nil
	})
	if err != nil {
		return nil, nil, err
	}
	sort.Strings(goMods)

	byPath := map[string]*Module{}
	// dirToModulePath lets replace-target directories resolve to module paths.
	dirToModulePath := map[string]string{}
	infos := map[string]goModInfo{} // module path -> parsed info

	var warnings []string

	for _, gm := range goMods {
		body, rerr := os.ReadFile(gm)
		if rerr != nil {
			warnings = append(warnings, "could not read "+gm+": "+rerr.Error())
			continue
		}
		info := parseGoMod(string(body))
		if info.path == "" {
			warnings = append(warnings, "no module declaration in "+gm)
			continue
		}
		dir := filepath.Dir(gm)
		relDir, _ := filepath.Rel(root, dir)
		relGoMod, _ := filepath.Rel(root, gm)
		// The boundarygate tool itself is infrastructure; never treat it as a
		// graph node even though its path is github.com/nexuscrm/boundarygate.
		if info.path == "github.com/nexuscrm/boundarygate" {
			continue
		}
		m := &Module{
			Path:            info.path,
			Dir:             dir,
			RelDir:          relDir,
			GoModRel:        relGoMod,
			RequireVersions: info.requireVersions,
		}
		byPath[info.path] = m
		dirToModulePath[filepath.Clean(dir)] = info.path
		infos[info.path] = info
	}

	// Resolve first-party require edges and replace targets into the graph.
	for modPath, m := range byPath {
		info := infos[modPath]
		reqSet := map[string]bool{}
		for _, r := range info.requires {
			if firstParty(r) {
				reqSet[r] = true
			}
		}
		// Replace targets matter for two reasons: (a) they reveal first-party
		// modules referenced via a different module path that maps to a local
		// dir, and (b) they reveal references to modules NOT in the tree (the
		// app-packs dialects), which we surface as Missing placeholders.
		replSet := map[string]bool{}
		for old, target := range info.replaces {
			if !firstParty(old) {
				continue
			}
			// Resolve the replacement target directory (relative paths only).
			if strings.HasPrefix(target, ".") || strings.HasPrefix(target, "/") {
				absTarget := target
				if !filepath.IsAbs(target) {
					absTarget = filepath.Clean(filepath.Join(m.Dir, target))
				}
				if resolved, ok := dirToModulePath[absTarget]; ok {
					replSet[resolved] = true
				} else if _, statErr := os.Stat(filepath.Join(absTarget, "go.mod")); statErr != nil {
					// Target dir has no go.mod in the checked-out tree -> Missing.
					if _, exists := byPath[old]; !exists {
						byPath[old] = &Module{Path: old, Missing: true}
					}
					replSet[old] = true
				}
			}
		}
		for r := range reqSet {
			m.Requires = append(m.Requires, r)
		}
		for r := range replSet {
			m.Replaces = append(m.Replaces, r)
		}
		sort.Strings(m.Requires)
		sort.Strings(m.Replaces)
	}

	return byPath, warnings, nil
}

// firstPartyEdges returns the deduplicated set of first-party dependency edges
// (require directives only — the actual build graph) for a module. Replace
// directives are NOT edges; they only rewire where a required module resolves.
func (m *Module) firstPartyEdges() []string {
	out := append([]string(nil), m.Requires...)
	sort.Strings(out)
	return out
}

// countLOC counts non-blank lines across *.go files directly owned by a module
// directory, excluding any nested module subtree (so backend doesn't absorb
// backend/distributions).
func countLOC(m *Module, allDirs []string) int {
	if m.Missing {
		return 0
	}
	// Nested module dirs to exclude.
	var nested []string
	for _, d := range allDirs {
		if d == m.Dir {
			continue
		}
		if strings.HasPrefix(d, m.Dir+string(os.PathSeparator)) {
			nested = append(nested, d)
		}
	}
	total := 0
	_ = filepath.WalkDir(m.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" || base == "testdata" {
				return filepath.SkipDir
			}
			for _, n := range nested {
				if path == n {
					return filepath.SkipDir
				}
			}
			return nil
		}
		if !strings.HasSuffix(path, ".go") {
			return nil
		}
		data, rerr := os.ReadFile(path)
		if rerr != nil {
			return nil
		}
		for _, ln := range strings.Split(string(data), "\n") {
			if strings.TrimSpace(ln) != "" {
				total++
			}
		}
		return nil
	})
	return total
}
