package analysis

import (
	"fmt"
	"path"
	"sort"
	"strings"

	"github.com/zzet/gortex/internal/config"
	"github.com/zzet/gortex/internal/graph"
)

// EvaluateArchitecture checks the declarative architecture DSL — named
// layers with directional allow/deny constraints — against a set of
// changed symbols.
//
// For each changed symbol it resolves the symbol's layer, walks its
// outgoing call / reference edges, resolves each target's layer, and
// reports a violation when a cross-layer dependency breaks the source
// layer's allow/deny rules. Symbols in no declared layer, and edges
// to such symbols, are unconstrained.
func EvaluateArchitecture(g *graph.Graph, arch config.ArchitectureConfig, changedSymbolIDs []string) []GuardViolation {
	if g == nil || arch.IsEmpty() {
		return nil
	}
	names := sortedLayerNames(arch.Layers)

	var violations []GuardViolation
	seen := make(map[string]bool)
	for _, id := range changedSymbolIDs {
		n := g.GetNode(id)
		if n == nil {
			continue
		}
		fromLayer := layerOf(effectivePath(n), arch.Layers, names)
		if fromLayer == "" {
			continue
		}
		for _, e := range g.GetOutEdges(id) {
			if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
				continue
			}
			target := g.GetNode(e.To)
			if target == nil {
				continue
			}
			toLayer := layerOf(effectivePath(target), arch.Layers, names)
			if toLayer == "" || toLayer == fromLayer {
				continue
			}
			ok, reason := layerAllows(arch.Layers[fromLayer], fromLayer, toLayer)
			if ok {
				continue
			}
			key := id + "\x00" + e.To
			if seen[key] {
				continue
			}
			seen[key] = true
			violations = append(violations, GuardViolation{
				RuleName: "layer:" + fromLayer,
				Kind:     "layer",
				Description: fmt.Sprintf("%s (layer %s) %s %s (layer %s): %s",
					n.ID, fromLayer, e.Kind, target.ID, toLayer, reason),
				Violator:  n.ID,
				LayerFrom: fromLayer,
				LayerTo:   toLayer,
				EdgeType:  string(e.Kind),
			})
		}
	}
	violations = append(violations, evaluateArchRules(g, arch, changedSymbolIDs, names)...)
	return violations
}

// evaluateArchRules checks the per-layer / per-pattern dependency-cone
// rules — fan-out caps and caller-boundary restrictions — for a set
// of changed symbols.
func evaluateArchRules(g *graph.Graph, arch config.ArchitectureConfig, changedSymbolIDs, layerNames []string) []GuardViolation {
	if len(arch.Rules) == 0 {
		return nil
	}
	var violations []GuardViolation
	for _, id := range changedSymbolIDs {
		n := g.GetNode(id)
		if n == nil {
			continue
		}
		ep := effectivePath(n)
		nodeLayer := layerOf(ep, arch.Layers, layerNames)
		for _, rule := range arch.Rules {
			if !ruleApplies(rule, ep, nodeLayer) {
				continue
			}
			label := archRuleLabel(rule)
			if rule.MaxFanOut > 0 {
				if fan := distinctCallTargets(g, id); fan > rule.MaxFanOut {
					violations = append(violations, GuardViolation{
						RuleName: label,
						Kind:     "fan_out",
						Description: ruleMessage(rule, fmt.Sprintf(
							"%s has dependency fan-out %d, exceeding the limit of %d",
							n.ID, fan, rule.MaxFanOut)),
						Violator: n.ID,
					})
				}
			}
			if len(rule.DenyCallersOutside) > 0 {
				seen := make(map[string]bool)
				for _, e := range g.GetInEdges(id) {
					if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
						continue
					}
					caller := g.GetNode(e.From)
					if caller == nil || seen[caller.ID] {
						continue
					}
					seen[caller.ID] = true
					cp := effectivePath(caller)
					if callerWithinBoundary(cp, rule, layerOf(cp, arch.Layers, layerNames)) {
						continue
					}
					violations = append(violations, GuardViolation{
						RuleName: label,
						Kind:     "caller_boundary",
						Description: ruleMessage(rule, fmt.Sprintf(
							"%s calls into %s from outside the permitted set", caller.ID, n.ID)),
						Violator: caller.ID,
						EdgeType: string(e.Kind),
					})
				}
			}
		}
	}
	return violations
}

// ruleApplies reports whether an architecture rule is scoped to a
// symbol. A rule with neither a Layer nor a Pattern selector matches
// nothing; when both are set the symbol must satisfy both.
func ruleApplies(rule config.ArchRule, effPath, nodeLayer string) bool {
	if rule.Layer == "" && rule.Pattern == "" {
		return false
	}
	if rule.Layer != "" && nodeLayer != rule.Layer {
		return false
	}
	if rule.Pattern != "" && !globMatch(rule.Pattern, effPath) {
		return false
	}
	return true
}

// callerWithinBoundary reports whether a caller is permitted to depend
// on a symbol guarded by a deny_callers_outside rule. The guarded set
// may always call within itself; every other caller must match one of
// the allowlist globs.
func callerWithinBoundary(callerPath string, rule config.ArchRule, callerLayer string) bool {
	if ruleApplies(rule, callerPath, callerLayer) {
		return true
	}
	for _, allow := range rule.DenyCallersOutside {
		if globMatch(allow, callerPath) {
			return true
		}
	}
	return false
}

// distinctCallTargets counts the distinct symbols a node calls or
// references — the dependency-cone size.
func distinctCallTargets(g *graph.Graph, id string) int {
	seen := make(map[string]bool)
	for _, e := range g.GetOutEdges(id) {
		if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
			continue
		}
		seen[e.To] = true
	}
	return len(seen)
}

// archRuleLabel derives a stable rule name for a violation.
func archRuleLabel(rule config.ArchRule) string {
	switch {
	case rule.Name != "":
		return rule.Name
	case rule.Layer != "":
		return "arch:layer:" + rule.Layer
	case rule.Pattern != "":
		return "arch:pattern:" + rule.Pattern
	default:
		return "arch:rule"
	}
}

// ruleMessage prefixes a rule's configured Message onto the derived
// description when one is set.
func ruleMessage(rule config.ArchRule, detail string) string {
	if rule.Message != "" {
		return rule.Message + ": " + detail
	}
	return detail
}

// layerAllows reports whether a dependency from one layer to another
// is permitted, and the human-readable reason when it is not.
//
// Precedence: an explicit deny of the target layer always wins; then
// a non-empty Allow whitelist requires the target to be listed; with
// no whitelist a wildcard deny ("*") blocks every cross-layer edge.
func layerAllows(rule config.LayerRule, from, to string) (bool, string) {
	for _, d := range rule.Deny {
		if d == to {
			return false, fmt.Sprintf("layer %q denies dependencies on %q", from, to)
		}
	}
	if len(rule.Allow) > 0 {
		for _, a := range rule.Allow {
			if a == "*" || a == to {
				return true, ""
			}
		}
		return false, fmt.Sprintf("layer %q may depend only on %s, not %q",
			from, strings.Join(rule.Allow, ", "), to)
	}
	for _, d := range rule.Deny {
		if d == "*" {
			return false, fmt.Sprintf("layer %q denies all cross-layer dependencies", from)
		}
	}
	return true, ""
}

// layerOf returns the name of the layer a file belongs to, or "" when
// no layer claims it. names must be sorted so an ambiguous file
// (matched by two layers) resolves deterministically to the first.
func layerOf(filePath string, layers map[string]config.LayerRule, names []string) string {
	for _, name := range names {
		rule := layers[name]
		if len(rule.Paths) > 0 {
			for _, p := range rule.Paths {
				if globMatch(p, filePath) {
					return name
				}
			}
			continue
		}
		// A layer with no explicit paths claims files that carry the
		// layer name as a path segment — supports the terse config form.
		if pathHasSegment(filePath, name) {
			return name
		}
	}
	return ""
}

// sortedLayerNames returns the layer names in a stable order.
func sortedLayerNames(layers map[string]config.LayerRule) []string {
	names := make([]string, 0, len(layers))
	for name := range layers {
		names = append(names, name)
	}
	sort.Strings(names)
	return names
}

// effectivePath strips the multi-repo prefix from a node's file path
// so per-repo architecture globs match in both single- and multi-repo
// graphs.
func effectivePath(n *graph.Node) string {
	if n.RepoPrefix != "" {
		return strings.TrimPrefix(n.FilePath, n.RepoPrefix+"/")
	}
	return n.FilePath
}

// pathHasSegment reports whether seg appears as a full path segment of
// filePath (e.g. seg "domain" matches "internal/domain/user.go").
func pathHasSegment(filePath, seg string) bool {
	for _, part := range strings.Split(filePath, "/") {
		if part == seg {
			return true
		}
	}
	return false
}

// globMatch reports whether path matches a glob pattern. "**" matches
// any number of path segments (including zero); "*" and "?" match
// within a single segment via the stdlib path.Match rules.
func globMatch(pattern, p string) bool {
	return matchSegments(strings.Split(pattern, "/"), strings.Split(p, "/"))
}

func matchSegments(pat, seg []string) bool {
	for len(pat) > 0 {
		if pat[0] == "**" {
			if len(pat) == 1 {
				return true
			}
			for i := 0; i <= len(seg); i++ {
				if matchSegments(pat[1:], seg[i:]) {
					return true
				}
			}
			return false
		}
		if len(seg) == 0 {
			return false
		}
		if ok, err := path.Match(pat[0], seg[0]); err != nil || !ok {
			return false
		}
		pat, seg = pat[1:], seg[1:]
	}
	return len(seg) == 0
}
