package lib

import (
	"errors"
	"os"
	"regexp"
	"slices"
	"sort"
	"strings"
)

// triggers.go: advisory trigger suggestions for `context`. Declared triggers are
// semantic — only the model may apply them — but the *facts* that make one
// relevant are deterministic: sentinel files, workspace-artifact keywords, and
// the dispatch role. Suggesting from facts removes per-invocation guesswork and
// the two failure modes it causes (over-loading wastes tokens; under-loading
// skips rules and costs a repair round). Suggestions are additive only: they
// never veto a caller-selected trigger and are never applied automatically.

// triggerFacts is the evidence base for suggestions.
type triggerFacts struct {
	role        string
	afk         bool
	afkParallel bool
	principles  bool
	aiSpec      bool   // featureDir/ai-spec.md exists: the spec process marked an AI feature
	corpus      string // lowercased text of spec/plan/tasks/design brief
}

// triggerEvidenceFiles bounds which workspace artifacts feed keyword matching.
var triggerEvidenceFiles = []string{"spec.md", "plan.md", "tasks.md", "design-brief.md"}

const triggerEvidenceFileCap = 256 << 10

var (
	uiSignalRe   = regexp.MustCompile(`\b(frontend|front-end|\bui\b|\bux\b|css|tailwind|stylesheet|component|responsive|layout|browser|viewport|screen reader|landing page|design[- ]brief|react|vue|svelte|next\.?js)\b`)
	secSignalRe  = regexp.MustCompile(`\b(auth|authentication|authorization|login|password|secret|token|credential|permission|encrypt|crypto|oauth|session|cookie|inject|xss|csrf|ssrf|webhook|untrusted|input validat)`)
	tddSignalRe  = regexp.MustCompile(`\b(tdd|test[- ]first|test driven)\b`)
	topoSignalRe = regexp.MustCompile(`(migrat|\bschema\b|\bsql\b|database|postgres|mysql|sqlite|mongo|\bqueue\b|kafka|rabbitmq|redis|\bcache\b|webhook|monorepo|multi[- ]root|multi[- ]service|microservice|\bgrpc\b|graphql|\brest\b|\bapi\b|message broker)`)
	wfaSignalRe  = regexp.MustCompile(`workflow[- ]artifact`)
	aiSignalRe   = regexp.MustCompile(`\b(llms?|prompts?|rag|embeddings?|model output|agents?|agentic|tool[- ]use|skill packages?|mcp|chatbot)\b`)
	// aiAnnexRe drops the spec template's always-present "AI-SPEC annex" section
	// (its boilerplate names LLM/RAG/agent) up to the next heading, kept via $1.
	aiAnnexRe = regexp.MustCompile(`(?ms)^#{1,6}[ \t]*ai-spec annex[^\n]*\n.*?(^#|\z)`)
)

// keepModelSurface reports whether a --role bundle inlines <!-- model-surface -->
// sections. Unlike suggestions this is decided by the engine, and it fails
// safe: the security auditor always keeps them, and so does any role whose
// spec/plan/tasks corpus is absent, whose feature has an ai-spec.md, or whose
// corpus (minus the template's AI-SPEC annex) names a model, agent, RAG, or
// tool surface.
func keepModelSurface(role string, facts triggerFacts) bool {
	return roleBase(role) == "security-auditor" || facts.aiSpec || strings.TrimSpace(facts.corpus) == "" ||
		aiSignalRe.MatchString(aiAnnexRe.ReplaceAllString(facts.corpus, "$1"))
}

// triggerSignals maps declared trigger-name shapes to facts. nameRe is matched
// case-insensitively against each declared (unselected) trigger name.
var triggerSignals = []struct {
	nameRe *regexp.Regexp
	fires  func(triggerFacts) bool
}{
	{regexp.MustCompile(`afk`), func(f triggerFacts) bool { return f.afk }},
	{regexp.MustCompile(`parallel`), func(f triggerFacts) bool { return f.afkParallel }},
	{regexp.MustCompile(`frontend|^ui$|ux|design`), func(f triggerFacts) bool { return uiSignalRe.MatchString(f.corpus) }},
	{regexp.MustCompile(`security|^sec`), func(f triggerFacts) bool { return secSignalRe.MatchString(f.corpus) }},
	{regexp.MustCompile(`tdd`), func(f triggerFacts) bool { return tddSignalRe.MatchString(f.corpus) }},
	{regexp.MustCompile(`applicability|topology|data[-_]?integrity|integration|repository`), func(f triggerFacts) bool {
		return topoSignalRe.MatchString(f.corpus)
	}},
	{regexp.MustCompile(`principles`), func(f triggerFacts) bool { return f.principles }},
	{regexp.MustCompile(`workflow[-_]?artifacts?`), func(f triggerFacts) bool {
		return wfaSignalRe.MatchString(f.corpus)
	}},
	// A writer role authors product source/tests; the craft-rule triggers apply.
	{regexp.MustCompile(`coding|errors?|patterns?|^dod$|definition|testing`), func(f triggerFacts) bool {
		return f.role == "slice-wright"
	}},
}

// suggestTriggers returns declared, unselected trigger names whose firing facts
// hold in this workspace. Sorted for stable output.
func suggestTriggers(manifest loadsManifest, opts contextOpts, facts triggerFacts) []string {
	var suggested []string
	for name := range manifest.Triggers {
		if slices.Contains(opts.triggers, name) {
			continue
		}
		lower := strings.ToLower(name)
		for _, signal := range triggerSignals {
			if signal.nameRe.MatchString(lower) && signal.fires(facts) {
				suggested = append(suggested, name)
				break
			}
		}
	}
	sort.Strings(suggested)
	return suggested
}

func gatherTriggerFacts(root, role string, sources *contextSources) (triggerFacts, error) {
	facts := triggerFacts{role: roleBase(role)}
	if root != "" {
		sentinels := contextSources{dir: root}
		data, err := sentinels.read("AFK")
		if err != nil && !errors.Is(err, os.ErrNotExist) {
			return facts, err
		}
		facts.afk = err == nil
		facts.afkParallel = afkAllowsParallel(string(data))
		_, err = sentinels.read("principles.md")
		if err != nil && !errors.Is(err, os.ErrNotExist) {
			return facts, err
		}
		facts.principles = err == nil
	}
	if sources.dir != "" {
		_, err := sources.read("ai-spec.md")
		if err != nil && !errors.Is(err, os.ErrNotExist) {
			return facts, err
		}
		facts.aiSpec = err == nil
		var corpus strings.Builder
		for _, name := range triggerEvidenceFiles {
			data, err := sources.read(name)
			if err != nil {
				if errors.Is(err, os.ErrNotExist) {
					continue
				}
				return facts, err
			}
			if len(data) > triggerEvidenceFileCap {
				data = data[:triggerEvidenceFileCap]
			}
			corpus.Write(data)
			corpus.WriteByte('\n')
		}
		facts.corpus = strings.ToLower(corpus.String())
	}
	return facts, nil
}

var afkParallelRe = regexp.MustCompile(`(?m)^\s*max_parallel\s*:\s*([0-9]+)\s*$`)

// afkAllowsParallel reports whether the AFK sentinel declares max_parallel > 1.
func afkAllowsParallel(content string) bool {
	m := afkParallelRe.FindStringSubmatch(content)
	if len(m) < 2 {
		return false
	}
	n := 0
	for _, r := range m[1] {
		n = n*10 + int(r-'0')
	}
	return n > 1
}

// triggerApplyCommand renders the command that applies the suggested triggers.
// The suggestions are never applied automatically (see the file header), so the
// agent needs a copy-pasteable form or the advisory list has no consumer.
func triggerApplyCommand(opts contextOpts, suggested []string) string {
	parts := []string{"devrites-engine", "context"}
	if opts.slug != "" {
		parts = append(parts, opts.slug)
	}
	for _, flag := range []struct{ name, value string }{
		{"--phase", opts.phase},
		{"--skill", opts.skill},
		{"--role", opts.role},
		{"--slice", opts.slice},
		{"--out", opts.out},
	} {
		if flag.value != "" {
			parts = append(parts, flag.name, flag.value)
		}
	}
	parts = append(parts, "--trigger", strings.Join(suggested, ","))
	if opts.purpose.parent != "" {
		parts = append(parts, "--prepare-for", frontierQuote(opts.purpose.parent))
	}
	if opts.purpose.research {
		parts = append(parts, "--sequence-research")
	}
	if opts.skillsRoot != "" {
		parts = append(parts, "--skills-root", frontierQuote(opts.skillsRoot))
	}
	return strings.Join(parts, " ")
}
