package agents

import (
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	exploitpkg "github.com/Armur-Ai/Pentest-Swarm-AI/internal/agent/exploit"
	"github.com/Armur-Ai/Pentest-Swarm-AI/internal/pipeline"
	"github.com/Armur-Ai/Pentest-Swarm-AI/internal/swarm/blackboard"
	"github.com/Armur-Ai/Pentest-Swarm-AI/internal/swarm/tuning"
	"github.com/Armur-Ai/Pentest-Swarm-AI/internal/swarm/verify"
	"github.com/google/uuid"
)

// defaultMaxAPITargets caps how many distinct API endpoints the exploit agent
// will spend an LLM plan + attack chain on per campaign. API endpoints arrive
// in bulk from the crawler, and each triggers an LLM call, so this bound keeps
// cost predictable — without it, a large SPA's hundreds of routes would each
// spawn a plan. Deduplication (collapsing /vehicle/42 and /vehicle/43) shrinks
// the working set first; the cap is the hard ceiling on top of that.
const defaultMaxAPITargets = 8

// ExploitAgent wakes on credible vulnerability findings (CVE matches and
// misconfigurations) and on API endpoints, builds an attack chain via the
// underlying exploit agent, and publishes EXPLOIT_CHAIN + EXPLOIT_RESULT.
//
// API endpoints are handled specially: business-logic flaws (BOLA/IDOR, mass
// assignment, broken auth) are invisible to scanners and only surface when the
// exploit agent crafts authenticated httpreq chains against the live API. The
// scheduler never hands this agent a stale finding — the MinPheromone predicate
// takes care of that at the DB/memory layer.
type ExploitAgent struct {
	exploit    *exploitpkg.ExploitAgent
	executor   *exploitpkg.Executor
	objective  string
	campaignID uuid.UUID
	parallel   int
	dryRun     bool
	tun        *tuning.Settings

	// API-endpoint cost controls (see defaultMaxAPITargets).
	maxAPITargets  int
	apiPlanCount   int64 // atomic: API endpoints we've built a plan for
	seenMu         sync.Mutex
	seenAPI        map[string]struct{} // normalized endpoint keys already handled
	seenSSRF       map[string]struct{} // endpoint|param SSRF sinks already probed
	seenAuthBypass map[string]struct{} // protected endpoints already bypass-probed
	seenSSTI       map[string]struct{} // endpoint|param template sinks already probed
	seenCmdInj     map[string]struct{} // endpoint|param command sinks already probed
	seenNoSQL      map[string]struct{} // login endpoints already NoSQL-probed
	seenXXE        map[string]struct{} // XML endpoints already XXE-probed
	seenLDAP       map[string]struct{} // ldap-search endpoints already probed
	seenOpenRedir  map[string]struct{} // endpoint|param redirect sinks already probed
	seenLFI        map[string]struct{} // endpoint|param file sinks already probed

	// probeSink, when set, receives one call per adaptive BOLA probe work-unit
	// (target + cross-user 200?) so the live dashboard/TUI can draw the exploit
	// phase fanning out into many concurrent probes. Pure telemetry — the
	// probes are real HTTP work that already runs, so this adds no cost.
	probeSink func(target string, ok bool)

	// scorer, when set, ranks the planner's candidate attack paths with Jev in
	// real time so the swarm pursues the best-scored strategy first and weights
	// its pheromone by the score — adaptive attack-path scoring. Fails open
	// (heuristic ranking) inside RankPaths. Optional.
	scorer    *exploitpkg.JevScorer
	scoreSink func(scored []exploitpkg.ScoredPath)

	// bolaVerifier, when set (--verify), re-proves a confirmed BOLA hit with an
	// external oracle against the live target, returning the receipt when
	// proven. Optional; nil means no external verification.
	bolaVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// massAssignVerifier, when set (--verify), re-proves a proven
	// mass-assignment chain with an external differential oracle, returning the
	// receipt when proven. Optional; nil means no external verification.
	massAssignVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// ssrfVerifier, when set (--verify), re-proves a suspected SSRF with an
	// external out-of-band oracle: it points the sink parameter at a callback
	// URL and returns the receipt when the target calls back. Optional; nil
	// means no external verification.
	ssrfVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// authBypassVerifier, when set (--verify), re-proves a 403-bypass with an
	// external differential oracle: it requests a transformed path (the attack)
	// against the original protected path (the control) and returns the receipt
	// when the attack succeeds where the control is denied. Optional.
	authBypassVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// sstiVerifier, when set (--verify), re-proves server-side template injection
	// with an external differential oracle: an arithmetic payload whose computed
	// product appears only when the engine evaluates it. Optional.
	sstiVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// cmdInjectionVerifier, when set (--verify), re-proves blind OS command
	// injection with an external out-of-band oracle: a shell polyglot curls a
	// callback URL, and a callback is the proof. Optional.
	cmdInjectionVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// nosqlVerifier, when set (--verify), re-proves a MongoDB-style NoSQL auth
	// bypass with an external differential oracle: an operator-object credential
	// logs in where wrong string creds are denied. Optional.
	nosqlVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// xxeVerifier, when set (--verify), re-proves XXE with an external out-of-band
	// oracle: an XML external entity points at a callback the parser fetches.
	// Optional.
	xxeVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// ldapVerifier, when set (--verify), re-proves LDAP injection with an external
	// differential oracle: a wildcard filter returns all directory entries where
	// a specific value returns none. Optional.
	ldapVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// openRedirectVerifier, when set (--verify), re-proves an open redirect with
	// the header-aware redirect oracle: a sink parameter reflected into a 3xx
	// Location pointing at an attacker host. Optional.
	openRedirectVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)

	// lfiVerifier, when set (--verify), re-proves path traversal / LFI with an
	// external differential oracle: a ../etc/passwd read returns the `root:`
	// signature the control does not. Optional.
	lfiVerifier func(ctx context.Context, target string, spec *verify.ProbeSpec) (proven bool, receipt string)
}

// SetBOLAVerifier installs the external-oracle verifier for BOLA hits (--verify).
// When set, a confirmed cross-user access is re-proven with a differential
// oracle and, if proven, stamped VERIFIED with the receipt. Optional.
func (a *ExploitAgent) SetBOLAVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.bolaVerifier = fn
}

// verifyBOLA re-proves a confirmed BOLA hit against the live target with the
// external oracle (when --verify wired a verifier) and stamps the finding
// VERIFIED + receipt on success. A nil verifier, an un-templatable URL, or an
// unproven verdict all leave the finding untouched — a no-op, never a downgrade.
func (a *ExploitAgent) verifyBOLA(ctx context.Context, cf *pipeline.ClassifiedFinding, hitURL, authHeader string) {
	if a.bolaVerifier == nil {
		return
	}
	tmpl, id, ok := exploitpkg.BOLATemplateAndID(hitURL)
	if !ok {
		return
	}
	spec, err := exploitpkg.BOLASpec(tmpl, id, authHeader)
	if err != nil {
		return
	}
	if proven, receipt := a.bolaVerifier(ctx, hitURL, spec); proven {
		cf.Verified = true
		cf.VerifiedBy = "differential-response: " + receipt
	}
}

// SetMassAssignVerifier installs the external-oracle verifier for proven
// mass-assignment chains (--verify). When set, a proven chain is re-proven with
// a differential oracle (attack body with the privileged field vs control body
// without it) and, if proven, stamped VERIFIED with the receipt. Optional.
func (a *ExploitAgent) SetMassAssignVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.massAssignVerifier = fn
}

// verifyMassAssignment re-proves a proven chain as a mass-assignment against the
// live target with the external differential oracle (when --verify wired a
// verifier) and stamps the finding VERIFIED + receipt on success. It scans the
// chain's steps (proof step first) for a body-mutating httpreq that smuggled a
// privileged field; a nil verifier, no such step, or an unproven verdict all
// leave the finding untouched — a no-op, never a downgrade.
func (a *ExploitAgent) verifyMassAssignment(ctx context.Context, cf *pipeline.ClassifiedFinding, path pipeline.AttackPath, vars map[string]string) {
	if a.massAssignVerifier == nil {
		return
	}
	for i := len(path.Steps) - 1; i >= 0; i-- {
		cmd := path.Steps[i].Command
		if cmd == "" {
			continue
		}
		spec, err := exploitpkg.MassAssignmentSpec(cmd, vars)
		if err != nil {
			continue // not a mass-assignment step — try the next
		}
		target := commandURL(cmd)
		if target == "" {
			target = cf.Target
		}
		if proven, receipt := a.massAssignVerifier(ctx, target, spec); proven {
			cf.Verified = true
			cf.VerifiedBy = "differential-response: " + receipt
			return
		}
	}
}

// commandURL extracts the --url/-u value of an httpreq command so the oracle
// re-targets the exact endpoint the step hit (the proof step's own host, which
// may differ from the campaign target). Returns "" when the step names no URL.
func commandURL(cmd string) string {
	fields := strings.Fields(cmd)
	for j, f := range fields {
		if (f == "--url" || f == "-u") && j+1 < len(fields) {
			return strings.Trim(fields[j+1], `"'`)
		}
	}
	return ""
}

// SetSSRFVerifier installs the external-oracle verifier for suspected SSRF
// (--verify). When set, a URL/host sink parameter is pointed at an out-of-band
// callback URL; if the target calls back, the finding is stamped VERIFIED with
// the receipt. Optional.
func (a *ExploitAgent) SetSSRFVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.ssrfVerifier = fn
}

// verifySSRF re-proves a suspected SSRF at endpointURL's sink parameter against
// the live target with the external out-of-band oracle (when --verify wired a
// verifier) and stamps the finding VERIFIED + receipt on a callback. A nil
// verifier, an unbuildable spec, or no callback (OOB never disproves) all leave
// the finding untouched — a no-op, never a downgrade.
func (a *ExploitAgent) verifySSRF(ctx context.Context, cf *pipeline.ClassifiedFinding, endpointURL, param, authHeader string) {
	if a.ssrfVerifier == nil {
		return
	}
	spec, err := exploitpkg.SSRFOOBSpec(endpointURL, param, authHeader)
	if err != nil {
		return
	}
	if proven, receipt := a.ssrfVerifier(ctx, endpointURL, spec); proven {
		cf.Verified = true
		cf.VerifiedBy = "oob-callback: " + receipt
	}
}

// SetAuthBypassVerifier installs the external-oracle verifier for 403-bypass
// (--verify). When set, a protected endpoint's known path-bypass variants are
// each re-proven with a differential oracle; the first that succeeds where the
// control is denied stamps the finding VERIFIED with the technique + receipt.
// Optional.
func (a *ExploitAgent) SetAuthBypassVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.authBypassVerifier = fn
}

// verifyAuthBypass re-proves a protected endpoint's path-based 403 bypasses
// against the live target with the external differential oracle (when --verify
// wired a verifier). It tries each AuthBypassVariant and, on the first Proven
// verdict, stamps the finding VERIFIED and returns the winning technique. A nil
// verifier or no working variant leaves the finding untouched — a no-op.
func (a *ExploitAgent) verifyAuthBypass(ctx context.Context, cf *pipeline.ClassifiedFinding, endpointURL, authHeader string) (technique string, ok bool) {
	if a.authBypassVerifier == nil {
		return "", false
	}
	stamp := func(technique, receipt string) {
		cf.Verified = true
		cf.VerifiedBy = "differential-response (" + technique + "): " + receipt
	}
	// Path-normalization variants first (a served path-variant vs the denied
	// canonical path).
	for _, v := range exploitpkg.AuthBypassVariants(requestTarget(endpointURL)) {
		spec, err := exploitpkg.AuthBypassSpec(endpointURL, v, authHeader)
		if err != nil {
			continue
		}
		if proven, receipt := a.authBypassVerifier(ctx, endpointURL, spec); proven {
			stamp(v.Technique, receipt)
			return v.Technique, true
		}
	}
	// Header tricks next (URL-override + IP-spoof).
	headerSpecs, err := exploitpkg.AuthBypassHeaderSpecs(endpointURL, authHeader)
	if err != nil {
		return "", false
	}
	for _, ts := range headerSpecs {
		if proven, receipt := a.authBypassVerifier(ctx, endpointURL, ts.Spec); proven {
			stamp(ts.Technique, receipt)
			return ts.Technique, true
		}
	}
	return "", false
}

// requestTarget returns the path+query of a URL (defaulting to "/"), split by
// hand so the bypass variants operate on the verbatim path.
func requestTarget(rawURL string) string {
	rest := rawURL
	for _, p := range []string{"https://", "http://"} {
		rest = strings.TrimPrefix(rest, p)
	}
	if i := strings.IndexAny(rest, "/?#"); i >= 0 {
		return rest[i:]
	}
	return "/"
}

// SetSSTIVerifier installs the external-oracle verifier for server-side template
// injection (--verify). When set, a reflected parameter is probed with a
// polyglot arithmetic payload; if it evaluates, the engine is identified and the
// finding is stamped VERIFIED. Optional.
func (a *ExploitAgent) SetSSTIVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.sstiVerifier = fn
}

// verifySSTI re-proves SSTI at endpointURL's parameter against the live target.
// It gates cheaply on a polyglot payload (one probe pair), and only on a Proven
// polyglot does it run the per-engine specs to name the engine for the report.
// Returns the winning technique. A nil verifier or a non-evaluating reflection
// leaves the finding untouched — a no-op.
func (a *ExploitAgent) verifySSTI(ctx context.Context, cf *pipeline.ClassifiedFinding, endpointURL, param, authHeader string) (technique string, ok bool) {
	if a.sstiVerifier == nil {
		return "", false
	}
	poly, err := exploitpkg.SSTIPolyglotSpec(endpointURL, param, authHeader)
	if err != nil {
		return "", false
	}
	if proven, _ := a.sstiVerifier(ctx, endpointURL, poly); !proven {
		return "", false // cheap gate: not a templating sink
	}
	// Identify the engine for the report (best-effort).
	specs, err := exploitpkg.SSTISpecs(endpointURL, param, authHeader)
	if err == nil {
		for _, ts := range specs {
			if proven, receipt := a.sstiVerifier(ctx, endpointURL, ts.Spec); proven {
				cf.Verified = true
				cf.VerifiedBy = "differential-response (" + ts.Technique + "): " + receipt
				return ts.Technique, true
			}
		}
	}
	// Polyglot proved but no single engine pinned — still a real finding.
	cf.Verified = true
	cf.VerifiedBy = "differential-response (ssti:polyglot): template expression evaluated to the expected product"
	return "ssti:polyglot", true
}

// SetCmdInjectionVerifier installs the external-oracle verifier for blind OS
// command injection (--verify). When set, a parameter is injected with a shell
// polyglot that curls a callback URL; a callback stamps the finding VERIFIED.
// Optional.
func (a *ExploitAgent) SetCmdInjectionVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.cmdInjectionVerifier = fn
}

// verifyCmdInjection re-proves blind OS command injection at endpointURL's
// parameter against the live target with the external out-of-band oracle (when
// --verify wired a verifier) and stamps the finding VERIFIED + receipt on a
// callback. A nil verifier, an unbuildable spec, or no callback (OOB never
// disproves) all leave the finding untouched — a no-op.
func (a *ExploitAgent) verifyCmdInjection(ctx context.Context, cf *pipeline.ClassifiedFinding, endpointURL, param, authHeader string) {
	if a.cmdInjectionVerifier == nil {
		return
	}
	spec, err := exploitpkg.CmdInjectionOOBSpec(endpointURL, param, authHeader)
	if err != nil {
		return
	}
	if proven, receipt := a.cmdInjectionVerifier(ctx, endpointURL, spec); proven {
		cf.Verified = true
		cf.VerifiedBy = "oob-callback: " + receipt
	}
}

// SetNoSQLVerifier installs the external-oracle verifier for NoSQL auth bypass
// (--verify). When set, a login endpoint is probed with operator-object
// credentials across common field-name pairs; a bypass is stamped VERIFIED.
// Optional.
func (a *ExploitAgent) SetNoSQLVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.nosqlVerifier = fn
}

// verifyNoSQL re-proves a NoSQL auth bypass at a login endpoint against the live
// target. It tries each common credential field-pair and, on the first Proven
// verdict (operator-object login succeeds where the control is denied), stamps
// the finding VERIFIED and returns the winning technique. A nil verifier or no
// working pair leaves the finding untouched — a no-op.
func (a *ExploitAgent) verifyNoSQL(ctx context.Context, cf *pipeline.ClassifiedFinding, endpointURL, authHeader string) (technique string, ok bool) {
	if a.nosqlVerifier == nil {
		return "", false
	}
	specs, err := exploitpkg.NoSQLAuthBypassSpecs(endpointURL, authHeader)
	if err != nil {
		return "", false
	}
	for _, ts := range specs {
		if proven, receipt := a.nosqlVerifier(ctx, endpointURL, ts.Spec); proven {
			cf.Verified = true
			cf.VerifiedBy = "differential-response (" + ts.Technique + "): " + receipt
			return ts.Technique, true
		}
	}
	return "", false
}

// SetXXEVerifier installs the external-oracle verifier for XXE (--verify). When
// set, an XML-accepting endpoint is POSTed an external-entity payload pointing
// at an out-of-band callback; a callback stamps the finding VERIFIED. Optional.
func (a *ExploitAgent) SetXXEVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.xxeVerifier = fn
}

// verifyXXE re-proves XXE at an XML-accepting endpoint against the live target
// with the external out-of-band oracle (when --verify wired a verifier) and
// stamps the finding VERIFIED + receipt on a callback. A nil verifier, an
// unbuildable spec, or no callback are all no-ops.
func (a *ExploitAgent) verifyXXE(ctx context.Context, cf *pipeline.ClassifiedFinding, endpointURL, authHeader string) {
	if a.xxeVerifier == nil {
		return
	}
	spec, err := exploitpkg.XXEOOBSpec(endpointURL, authHeader)
	if err != nil {
		return
	}
	if proven, receipt := a.xxeVerifier(ctx, endpointURL, spec); proven {
		cf.Verified = true
		cf.VerifiedBy = "oob-callback: " + receipt
	}
}

// SetLDAPVerifier installs the external-oracle verifier for LDAP injection
// (--verify). When set, an LDAP-ish search endpoint is probed with a wildcard
// across common parameter names; a result-set divergence stamps the finding
// VERIFIED. Optional.
func (a *ExploitAgent) SetLDAPVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.ldapVerifier = fn
}

// verifyLDAP re-proves LDAP injection at a search endpoint. It tries each common
// parameter name and, on the first Proven verdict (wildcard returns entries the
// control does not), stamps the finding VERIFIED and returns the winning
// technique. A nil verifier or no working parameter is a no-op.
func (a *ExploitAgent) verifyLDAP(ctx context.Context, cf *pipeline.ClassifiedFinding, endpointURL, authHeader string) (technique string, ok bool) {
	if a.ldapVerifier == nil {
		return "", false
	}
	specs, err := exploitpkg.LDAPInjectionSpecs(endpointURL, authHeader)
	if err != nil {
		return "", false
	}
	for _, ts := range specs {
		if proven, receipt := a.ldapVerifier(ctx, endpointURL, ts.Spec); proven {
			cf.Verified = true
			cf.VerifiedBy = "differential-response (" + ts.Technique + "): " + receipt
			return ts.Technique, true
		}
	}
	return "", false
}

// SetOpenRedirectVerifier installs the external-oracle verifier for open redirect
// (--verify). When set, a redirect-sink parameter is pointed at a sentinel
// attacker URL; a 3xx Location to that host stamps the finding VERIFIED.
// Optional.
func (a *ExploitAgent) SetOpenRedirectVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.openRedirectVerifier = fn
}

// verifyOpenRedirect re-proves an open redirect at endpointURL's sink parameter
// and stamps the finding VERIFIED + receipt when the target redirects to the
// attacker host. A nil verifier, an unbuildable spec, or a same-site redirect
// are all no-ops.
func (a *ExploitAgent) verifyOpenRedirect(ctx context.Context, cf *pipeline.ClassifiedFinding, endpointURL, param, authHeader string) {
	if a.openRedirectVerifier == nil {
		return
	}
	spec, err := exploitpkg.OpenRedirectSpec(endpointURL, param, authHeader)
	if err != nil {
		return
	}
	if proven, receipt := a.openRedirectVerifier(ctx, endpointURL, spec); proven {
		cf.Verified = true
		cf.VerifiedBy = "open-redirect: " + receipt
	}
}

// SetLFIVerifier installs the external-oracle verifier for path traversal / LFI
// (--verify). When set, a file-ish parameter is traversed to /etc/passwd; the
// `root:` signature stamps the finding VERIFIED. Optional.
func (a *ExploitAgent) SetLFIVerifier(fn func(ctx context.Context, target string, spec *verify.ProbeSpec) (bool, string)) {
	a.lfiVerifier = fn
}

// verifyLFI re-proves path traversal / LFI at endpointURL's file parameter and
// stamps the finding VERIFIED + receipt when the passwd signature appears. A nil
// verifier, an unbuildable spec, or no leak are all no-ops.
func (a *ExploitAgent) verifyLFI(ctx context.Context, cf *pipeline.ClassifiedFinding, endpointURL, param, authHeader string) {
	if a.lfiVerifier == nil {
		return
	}
	spec, err := exploitpkg.PathTraversalSpec(endpointURL, param, authHeader)
	if err != nil {
		return
	}
	if proven, receipt := a.lfiVerifier(ctx, endpointURL, spec); proven {
		cf.Verified = true
		cf.VerifiedBy = "differential-response: " + receipt
	}
}

// isLFIParam reports whether a parameter name commonly names a file to read.
func isLFIParam(name string) bool {
	for _, p := range exploitpkg.CommonLFIParams() {
		if strings.EqualFold(name, p) {
			return true
		}
	}
	return false
}

// SetProbeSink installs the per-probe telemetry callback (see probeSink).
// Optional; safe to leave unset for a silent run.
func (a *ExploitAgent) SetProbeSink(fn func(target string, ok bool)) { a.probeSink = fn }

// SetPathScorer installs the adaptive Jev path scorer. When set, the agent ranks
// the planner's candidate paths and pursues the best-scored one first. Optional.
func (a *ExploitAgent) SetPathScorer(s *exploitpkg.JevScorer) { a.scorer = s }

// SetScoreSink installs a telemetry callback that receives the ranked paths each
// time adaptive scoring runs, so the dashboard/TUI can render the scoreboard.
func (a *ExploitAgent) SetScoreSink(fn func(scored []exploitpkg.ScoredPath)) { a.scoreSink = fn }

// liveState summarises what the swarm knows right now for the Jev scorer to
// reason against — the objective, the target, and the finding being exploited.
func (a *ExploitAgent) liveState(cf pipeline.ClassifiedFinding) string {
	return fmt.Sprintf("Objective: %s\nTarget: %s\nFinding under exploitation: %s [%s] category=%s",
		a.objective, cf.Target, cf.Title, cf.Severity, cf.AttackCategory)
}

// NewExploitAgent wires the existing exploit agent + executor into the swarm.
// Pass nil for tun to use the baked-in default pheromone tuning.
func NewExploitAgent(inner *exploitpkg.ExploitAgent, exec *exploitpkg.Executor, objective string, campaignID uuid.UUID, parallel int, dryRun bool, tun *tuning.Settings) *ExploitAgent {
	if parallel <= 0 {
		parallel = 1
	}
	if tun == nil {
		tun = tuning.Default()
	}
	return &ExploitAgent{
		exploit:        inner,
		executor:       exec,
		objective:      objective,
		campaignID:     campaignID,
		parallel:       parallel,
		dryRun:         dryRun,
		tun:            tun,
		maxAPITargets:  defaultMaxAPITargets,
		seenAPI:        make(map[string]struct{}),
		seenSSRF:       make(map[string]struct{}),
		seenAuthBypass: make(map[string]struct{}),
		seenSSTI:       make(map[string]struct{}),
		seenCmdInj:     make(map[string]struct{}),
		seenNoSQL:      make(map[string]struct{}),
		seenXXE:        make(map[string]struct{}),
		seenLDAP:       make(map[string]struct{}),
		seenOpenRedir:  make(map[string]struct{}),
		seenLFI:        make(map[string]struct{}),
	}
}

// Name implements swarm.Agent.
func (a *ExploitAgent) Name() string { return "exploit" }

// Trigger implements swarm.Agent. The agent wakes on credible vulnerability
// findings and on discovered endpoints; per-type gating and the API cap live
// in Handle (a predicate can only filter by type + pheromone).
func (a *ExploitAgent) Trigger() blackboard.Predicate {
	return blackboard.Predicate{
		Types: []blackboard.FindingType{
			blackboard.TypeCVEMatch,
			blackboard.TypeMisconfig,
			blackboard.TypeHTTPEndpoint,
			blackboard.TypeExploitPlaybook,
		},
		MinPheromone: 0.3, // credible, recent findings only
	}
}

// MaxConcurrency implements swarm.Agent.
func (a *ExploitAgent) MaxConcurrency() int { return a.parallel }

// Handle routes a finding to the right exploitation path: vulnerability
// findings (CVE/misconfig) carry a ClassifiedFinding; HTTP endpoints are
// filtered, deduplicated, capped, and turned into an API-attack target.
func (a *ExploitAgent) Handle(ctx context.Context, f blackboard.Finding, board blackboard.Board) error {
	switch f.Type {
	case blackboard.TypeExploitPlaybook:
		return a.handlePlaybook(ctx, f, board)
	case blackboard.TypeHTTPEndpoint:
		return a.handleAPIEndpoint(ctx, f, board)
	}

	var cf pipeline.ClassifiedFinding
	if err := json.Unmarshal(f.Data, &cf); err != nil {
		return fmt.Errorf("decode classified finding: %w", err)
	}
	return a.runExploit(ctx, board, cf)
}

// handlePlaybook runs a verified, ready-made attack chain deterministically —
// no LLM planning. This is the reliable path for a known high-value finding
// (crAPI's BOLA): the chain and its captures were confirmed against a live
// target, so rather than hoping the model reconstructs a precise stateful
// chain, we execute it directly. The chain is still published as an
// EXPLOIT_CHAIN and each step as an EXPLOIT_RESULT; if the final (proof) step
// succeeds, a report-ready ClassifiedFinding is written so it lands in the
// report's findings, not just the attack narrative.
func (a *ExploitAgent) handlePlaybook(ctx context.Context, f blackboard.Finding, board blackboard.Board) error {
	var path pipeline.AttackPath
	if err := json.Unmarshal(f.Data, &path); err != nil {
		return nil // not a chain payload we understand
	}
	if len(path.Steps) == 0 {
		return nil
	}

	// Publish the chain so the report and other agents can see it.
	chainData, _ := json.Marshal(path)
	chainBase, chainHalf := a.tun.Lookup(blackboard.TypeExploitChain)
	chainID, _ := board.Write(ctx, blackboard.Finding{
		CampaignID:    a.campaignID,
		AgentName:     a.Name(),
		Type:          blackboard.TypeExploitChain,
		Target:        f.Target,
		Data:          chainData,
		PheromoneBase: chainBase,
		HalfLifeSec:   chainHalf,
	})

	if a.dryRun || a.executor == nil {
		return nil
	}

	results, vars, _ := a.executor.ExecuteChainCapturing(ctx, path.Steps, a.campaignID)

	// Pair results back to their step names (ExecuteChain skips empty commands).
	stepNames := make([]string, 0, len(path.Steps))
	for _, s := range path.Steps {
		if s.Command != "" {
			stepNames = append(stepNames, s.Name)
		}
	}
	var lastSuccess bool
	var proofOutput string
	for i, res := range results {
		name := ""
		if i < len(stepNames) {
			name = stepNames[i]
		}
		resData, _ := json.Marshal(map[string]any{"chain_id": chainID, "step": name, "result": res})
		pheromone, halfLife := a.tun.Lookup(blackboard.TypeExploitResult)
		if res.Success {
			pheromone = 1.0
		}
		_, _ = board.Write(ctx, blackboard.Finding{
			CampaignID:    a.campaignID,
			AgentName:     a.Name(),
			Type:          blackboard.TypeExploitResult,
			Target:        f.Target,
			Data:          resData,
			PheromoneBase: pheromone,
			HalfLifeSec:   halfLife,
		})
		lastSuccess = res.Success
		if res.Success {
			proofOutput = res.Output
		}
	}

	// The chain proves the vulnerability only if its final (proof) step
	// succeeded — for the BOLA chain, the cross-user read returning HTTP 200.
	if lastSuccess {
		a.publishPlaybookFinding(ctx, board, f.Target, path, proofOutput, vars)
	}

	// Adaptive loop (pillar ①): reuse the session this chain captured, harvest
	// the object ids it saw, and replay them across every id-bearing endpoint
	// the swarm has discovered — surfacing BOLA/IDOR the playbook never
	// scripted, and generalizing to targets with no curated profile at all.
	a.adaptiveSweep(ctx, board, results, vars)

	// Adaptive SSRF sweep (--verify only): point every discovered URL/host sink
	// parameter at an out-of-band callback and publish the ones that call back as
	// VERIFIED server-side request forgery — found against ground truth, not a
	// scripted playbook.
	a.ssrfSweep(ctx, board, vars)

	// Adaptive 403-bypass sweep (--verify only): for every endpoint recon saw
	// return 401/403, try the path-bypass variants and publish the ones that
	// cross the authorization boundary as VERIFIED findings.
	a.authBypassSweep(ctx, board, vars)

	// Adaptive SSTI sweep (--verify only): probe reflected query parameters with
	// a polyglot arithmetic payload and publish the ones that evaluate it as
	// VERIFIED server-side template injection.
	a.sstiSweep(ctx, board, vars)

	// Adaptive command-injection sweep (--verify only): inject a shell polyglot
	// that curls an out-of-band callback into query parameters and publish the
	// ones that call back as VERIFIED blind OS command injection.
	a.cmdInjectionSweep(ctx, board, vars)

	// Adaptive NoSQL auth-bypass sweep (--verify only): probe discovered login
	// endpoints with operator-object credentials and publish the ones that log in
	// as VERIFIED NoSQL authentication bypass.
	a.nosqlSweep(ctx, board, vars)

	// Adaptive XXE sweep (--verify only): POST an external-entity XML payload to
	// XML-accepting endpoints and publish the ones whose parser calls back as
	// VERIFIED XXE.
	a.xxeSweep(ctx, board, vars)

	// Adaptive LDAP-injection sweep (--verify only): probe LDAP-ish search
	// endpoints with a wildcard filter and publish result-set divergences as
	// VERIFIED LDAP injection.
	a.ldapSweep(ctx, board, vars)

	// Adaptive open-redirect sweep (--verify only): point redirect-sink params at
	// a sentinel attacker URL and publish the ones that 3xx-redirect to it as
	// VERIFIED open redirects.
	a.openRedirectSweep(ctx, board, vars)

	// Adaptive LFI sweep (--verify only): traverse file-ish parameters to
	// /etc/passwd and publish the ones that leak it as VERIFIED path traversal.
	a.lfiSweep(ctx, board, vars)
	return nil
}

// maxLFIProbes caps how many file-parameter sinks one LFI sweep probes.
const maxLFIProbes = 8

// lfiSweep probes discovered endpoints' file-ish query parameters (file, path,
// page, doc, ...) for path traversal / LFI via the injected differential
// verifier, publishing proven ones as VERIFIED (high) findings. Runs only under
// --verify (nil verifier → no-op), bounded, de-duped.
func (a *ExploitAgent) lfiSweep(ctx context.Context, board blackboard.Board, vars map[string]string) {
	if a.lfiVerifier == nil {
		return
	}
	authHeader := bearerFromVars(vars)
	probes := 0
	for _, epURL := range a.endpointURLs(ctx, board) {
		for _, param := range queryParamNames(epURL) {
			if probes >= maxLFIProbes {
				return
			}
			if !isLFIParam(param) {
				continue
			}
			key := epURL + "|" + param
			a.seenMu.Lock()
			_, dup := a.seenLFI[key]
			if !dup {
				a.seenLFI[key] = struct{}{}
			}
			a.seenMu.Unlock()
			if dup {
				continue
			}
			probes++

			cf := pipeline.ClassifiedFinding{
				ID:         uuid.New(),
				CampaignID: a.campaignID,
				Title:      "Path traversal / local file inclusion via parameter " + param,
				Description: "The swarm set the " + param + " parameter to a ../ traversal reaching " +
					"/etc/passwd and the response returned the file's contents — arbitrary local file read. " +
					"Confirmed against ground truth (the passwd signature appeared for the traversal payload " +
					"but not the control).",
				Severity:       pipeline.SeverityHigh,
				CVSSScore:      cvssForImpact(pipeline.SeverityHigh),
				AttackCategory: "path traversal",
				Confidence:     pipeline.Confidence("high"),
				Target:         epURL,
				ClassifiedAt:   time.Now(),
			}
			a.verifyLFI(ctx, &cf, epURL, param, authHeader)
			if a.probeSink != nil {
				a.probeSink(epURL, cf.Verified)
			}
			if !cf.Verified {
				continue
			}
			cf.Evidence = []pipeline.Evidence{{
				Type: "http_response", Content: cf.VerifiedBy, Timestamp: time.Now(),
				Description: "Differential proof: the traversal payload returned the /etc/passwd signature",
			}}
			data, _ := json.Marshal(cf)
			_, _ = board.Write(ctx, blackboard.Finding{
				CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
				Target: epURL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
			})
		}
	}
}

// maxOpenRedirectProbes caps how many redirect sinks one sweep probes.
const maxOpenRedirectProbes = 8

// openRedirectSweep probes discovered endpoints' redirect-sink parameters
// (DetectSSRFParams: url/redirect/next/return/...) for open redirect via the
// injected header-aware verifier, publishing proven ones as VERIFIED (medium)
// findings. Runs only under --verify (nil verifier → no-op), bounded, de-duped.
func (a *ExploitAgent) openRedirectSweep(ctx context.Context, board blackboard.Board, vars map[string]string) {
	if a.openRedirectVerifier == nil {
		return
	}
	authHeader := bearerFromVars(vars)
	probes := 0
	for _, epURL := range a.endpointURLs(ctx, board) {
		for _, param := range exploitpkg.DetectSSRFParams(epURL) {
			if probes >= maxOpenRedirectProbes {
				return
			}
			key := epURL + "|" + param
			a.seenMu.Lock()
			_, dup := a.seenOpenRedir[key]
			if !dup {
				a.seenOpenRedir[key] = struct{}{}
			}
			a.seenMu.Unlock()
			if dup {
				continue
			}
			probes++

			cf := pipeline.ClassifiedFinding{
				ID:         uuid.New(),
				CampaignID: a.campaignID,
				Title:      "Open redirect via parameter " + param,
				Description: "The swarm set the " + param + " parameter to an attacker-controlled URL and the " +
					"server issued a 3xx redirect to that external host — an open redirect usable for phishing " +
					"and OAuth-token theft. Confirmed against ground truth (Location header points at the " +
					"attacker host).",
				Severity:       pipeline.SeverityMedium,
				CVSSScore:      cvssForImpact(pipeline.SeverityMedium),
				AttackCategory: "open redirect",
				Confidence:     pipeline.Confidence("high"),
				Target:         epURL,
				ClassifiedAt:   time.Now(),
			}
			a.verifyOpenRedirect(ctx, &cf, epURL, param, authHeader)
			if a.probeSink != nil {
				a.probeSink(epURL, cf.Verified)
			}
			if !cf.Verified {
				continue
			}
			cf.Evidence = []pipeline.Evidence{{
				Type: "http_response", Content: cf.VerifiedBy, Timestamp: time.Now(),
				Description: "3xx Location header pointing at the attacker-controlled host",
			}}
			data, _ := json.Marshal(cf)
			_, _ = board.Write(ctx, blackboard.Finding{
				CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
				Target: epURL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
			})
		}
	}
}

// maxLDAPProbes caps how many LDAP-ish endpoints one sweep probes.
const maxLDAPProbes = 5

// ldapSweep probes LDAP-ish search endpoints (path contains "ldap") for LDAP
// injection via the injected differential verifier, publishing proven ones as
// VERIFIED (high) findings. Runs only under --verify (nil verifier → no-op),
// bounded, de-duplicated.
func (a *ExploitAgent) ldapSweep(ctx context.Context, board blackboard.Board, vars map[string]string) {
	if a.ldapVerifier == nil {
		return
	}
	authHeader := bearerFromVars(vars)
	probes := 0
	for _, epURL := range a.endpointURLs(ctx, board) {
		if probes >= maxLDAPProbes {
			return
		}
		if !strings.Contains(strings.ToLower(epURL), "ldap") {
			continue
		}
		a.seenMu.Lock()
		_, dup := a.seenLDAP[epURL]
		if !dup {
			a.seenLDAP[epURL] = struct{}{}
		}
		a.seenMu.Unlock()
		if dup {
			continue
		}
		probes++

		cf := pipeline.ClassifiedFinding{
			ID:         uuid.New(),
			CampaignID: a.campaignID,
			Title:      "LDAP injection",
			Description: "The swarm supplied a wildcard filter value to this directory-search endpoint and it " +
				"returned entries that a specific non-matching value did not — the parameter reaches the LDAP " +
				"filter unsanitised. Confirmed against ground truth (wildcard result set vs the control).",
			Severity:       pipeline.SeverityHigh,
			CVSSScore:      cvssForImpact(pipeline.SeverityHigh),
			AttackCategory: "ldap injection",
			Confidence:     pipeline.Confidence("high"),
			Target:         epURL,
			ClassifiedAt:   time.Now(),
		}
		_, ok := a.verifyLDAP(ctx, &cf, epURL, authHeader)
		if a.probeSink != nil {
			a.probeSink(epURL, ok)
		}
		if !ok {
			continue
		}
		cf.Evidence = []pipeline.Evidence{{
			Type: "http_response", Content: cf.VerifiedBy, Timestamp: time.Now(),
			Description: "Differential proof: wildcard filter returned entries the control did not",
		}}
		data, _ := json.Marshal(cf)
		_, _ = board.Write(ctx, blackboard.Finding{
			CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
			Target: epURL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
		})
	}
}

// maxXXEProbes caps how many XML endpoints one XXE sweep probes.
const maxXXEProbes = 6

// isXMLEndpoint reports whether a URL looks like it accepts XML (worth an XXE
// probe) — conservative so we don't POST XML at every endpoint.
func isXMLEndpoint(u string) bool {
	lu := strings.ToLower(u)
	return strings.Contains(lu, "xml") || strings.Contains(lu, "/import") || strings.Contains(lu, "soap") || strings.Contains(lu, "/rpc")
}

// xxeSweep probes XML-accepting endpoints for XXE via the injected out-of-band
// verifier, publishing proven ones as VERIFIED (high) findings. Runs only under
// --verify (nil verifier → no-op), bounded by maxXXEProbes, de-duplicated.
func (a *ExploitAgent) xxeSweep(ctx context.Context, board blackboard.Board, vars map[string]string) {
	if a.xxeVerifier == nil {
		return
	}
	authHeader := bearerFromVars(vars)
	probes := 0
	for _, epURL := range a.endpointURLs(ctx, board) {
		if probes >= maxXXEProbes {
			return
		}
		if !isXMLEndpoint(epURL) {
			continue
		}
		a.seenMu.Lock()
		_, dup := a.seenXXE[epURL]
		if !dup {
			a.seenXXE[epURL] = struct{}{}
		}
		a.seenMu.Unlock()
		if dup {
			continue
		}
		probes++

		cf := pipeline.ClassifiedFinding{
			ID:         uuid.New(),
			CampaignID: a.campaignID,
			Title:      "XML External Entity (XXE) injection",
			Description: "The swarm POSTed an XML document declaring an external entity pointing at an " +
				"out-of-band callback; the endpoint's parser resolved it and made the outbound request — " +
				"confirmed XXE against infrastructure we control (no response signal needed).",
			Severity:       pipeline.SeverityHigh,
			CVSSScore:      cvssForImpact(pipeline.SeverityHigh),
			AttackCategory: "xxe",
			Confidence:     pipeline.Confidence("high"),
			Target:         epURL,
			ClassifiedAt:   time.Now(),
		}
		a.verifyXXE(ctx, &cf, epURL, authHeader)
		if a.probeSink != nil {
			a.probeSink(epURL, cf.Verified)
		}
		if !cf.Verified {
			continue
		}
		cf.Evidence = []pipeline.Evidence{{
			Type: "oob_interaction", Content: cf.VerifiedBy, Timestamp: time.Now(),
			Description: "Out-of-band callback proving the XML parser resolved the external entity",
		}}
		data, _ := json.Marshal(cf)
		_, _ = board.Write(ctx, blackboard.Finding{
			CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
			Target: epURL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
		})
	}
}

// maxNoSQLProbes caps how many login endpoints one NoSQL sweep probes.
const maxNoSQLProbes = 6

// isLoginEndpoint reports whether a URL looks like a credential-accepting login
// route worth a NoSQL auth-bypass probe.
func isLoginEndpoint(u string) bool {
	lu := strings.ToLower(u)
	for _, kw := range []string{"login", "signin", "sign-in", "authenticate", "/auth", "session"} {
		if strings.Contains(lu, kw) {
			return true
		}
	}
	return false
}

// nosqlSweep probes discovered login endpoints for MongoDB-style NoSQL
// authentication bypass via the injected verifier, publishing proven ones as
// VERIFIED (critical) findings. Runs only under --verify (nil verifier → no-op),
// bounded by maxNoSQLProbes, de-duplicated across calls.
func (a *ExploitAgent) nosqlSweep(ctx context.Context, board blackboard.Board, vars map[string]string) {
	if a.nosqlVerifier == nil {
		return
	}
	authHeader := bearerFromVars(vars)
	probes := 0
	for _, epURL := range a.endpointURLs(ctx, board) {
		if probes >= maxNoSQLProbes {
			return
		}
		if !isLoginEndpoint(epURL) {
			continue
		}
		a.seenMu.Lock()
		_, dup := a.seenNoSQL[epURL]
		if !dup {
			a.seenNoSQL[epURL] = struct{}{}
		}
		a.seenMu.Unlock()
		if dup {
			continue
		}
		probes++

		cf := pipeline.ClassifiedFinding{
			ID:         uuid.New(),
			CampaignID: a.campaignID,
			Title:      "NoSQL authentication bypass",
			Description: "The swarm logged into this endpoint by replacing the credential fields with a MongoDB " +
				"operator object (e.g. {\"$gt\":\"\"}) that matches any user, while correct-shaped wrong " +
				"credentials were denied — the login query interpolates untrusted JSON into a Mongo filter. " +
				"Confirmed against ground truth (operator payload 2xx vs control denied).",
			Severity:       pipeline.SeverityCritical,
			CVSSScore:      cvssForImpact(pipeline.SeverityCritical),
			AttackCategory: "nosql_injection",
			Confidence:     pipeline.Confidence("high"),
			Target:         epURL,
			ClassifiedAt:   time.Now(),
		}
		_, ok := a.verifyNoSQL(ctx, &cf, epURL, authHeader)
		if a.probeSink != nil {
			a.probeSink(epURL, ok)
		}
		if !ok {
			continue
		}
		cf.Evidence = []pipeline.Evidence{{
			Type: "http_response", Content: cf.VerifiedBy, Timestamp: time.Now(),
			Description: "Differential proof: operator-object credentials authenticated where the control was denied",
		}}
		data, _ := json.Marshal(cf)
		_, _ = board.Write(ctx, blackboard.Finding{
			CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
			Target: epURL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
		})
	}
}

// maxCmdInjectionParams caps how many parameter sinks one command-injection
// sweep probes. Each is an OOB probe with a listen window, so the bound keeps
// the campaign's blast radius and wall-clock cost in check.
const maxCmdInjectionParams = 6

// cmdInjectionSweep probes discovered endpoints' query parameters for blind OS
// command injection via the injected out-of-band verifier. A parameter whose
// injected shell polyglot calls back is proven command injection — published as
// a VERIFIED (critical) finding; silence publishes nothing (OOB never
// disproves). Runs only under --verify (nil verifier → no-op), is bounded by
// maxCmdInjectionParams, and de-duplicates sinks across calls.
func (a *ExploitAgent) cmdInjectionSweep(ctx context.Context, board blackboard.Board, vars map[string]string) {
	if a.cmdInjectionVerifier == nil {
		return
	}
	authHeader := bearerFromVars(vars)
	probes := 0
	for _, epURL := range a.endpointURLs(ctx, board) {
		for _, param := range queryParamNames(epURL) {
			if probes >= maxCmdInjectionParams {
				return
			}
			key := epURL + "|" + param
			a.seenMu.Lock()
			_, dup := a.seenCmdInj[key]
			if !dup {
				a.seenCmdInj[key] = struct{}{}
			}
			a.seenMu.Unlock()
			if dup {
				continue
			}
			probes++

			cf := pipeline.ClassifiedFinding{
				ID:         uuid.New(),
				CampaignID: a.campaignID,
				Title:      "Blind OS command injection in parameter " + param,
				Description: "The swarm injected a shell payload into the " + param + " parameter that fetched an " +
					"out-of-band callback URL, and the target's machine made the request — confirmed blind OS " +
					"command injection against infrastructure we control (no response signal needed).",
				Severity:       pipeline.SeverityCritical,
				CVSSScore:      cvssForImpact(pipeline.SeverityCritical),
				AttackCategory: "command_injection",
				Confidence:     pipeline.Confidence("high"),
				Target:         epURL,
				ClassifiedAt:   time.Now(),
			}
			a.verifyCmdInjection(ctx, &cf, epURL, param, authHeader)
			if a.probeSink != nil {
				a.probeSink(epURL, cf.Verified)
			}
			if !cf.Verified {
				continue
			}
			cf.Evidence = []pipeline.Evidence{{
				Type: "oob_interaction", Content: cf.VerifiedBy, Timestamp: time.Now(),
				Description: "Out-of-band callback proving the injected command executed",
			}}
			data, _ := json.Marshal(cf)
			_, _ = board.Write(ctx, blackboard.Finding{
				CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
				Target: epURL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
			})
		}
	}
}

// maxSSTIParams caps how many parameter sinks one SSTI sweep probes. The polyglot
// gate makes a non-templating parameter cheap (one probe pair), but the cap still
// bounds the campaign's blast radius.
const maxSSTIParams = 8

// sstiSweep probes discovered endpoints' query parameters for server-side
// template injection via the injected verifier (polyglot gate, then per-engine
// identification). A parameter that evaluates the arithmetic payload is proven
// SSTI and published as a VERIFIED finding. Runs only under --verify (nil
// verifier → no-op), is bounded by maxSSTIParams, and de-duplicates sinks across
// calls.
func (a *ExploitAgent) sstiSweep(ctx context.Context, board blackboard.Board, vars map[string]string) {
	if a.sstiVerifier == nil {
		return
	}
	authHeader := bearerFromVars(vars)
	probes := 0
	for _, epURL := range a.endpointURLs(ctx, board) {
		for _, param := range queryParamNames(epURL) {
			if probes >= maxSSTIParams {
				return
			}
			key := epURL + "|" + param
			a.seenMu.Lock()
			_, dup := a.seenSSTI[key]
			if !dup {
				a.seenSSTI[key] = struct{}{}
			}
			a.seenMu.Unlock()
			if dup {
				continue
			}
			probes++

			cf := pipeline.ClassifiedFinding{
				ID:         uuid.New(),
				CampaignID: a.campaignID,
				Title:      "Server-Side Template Injection (SSTI) in parameter " + param,
				Description: "The swarm injected an arithmetic template payload into the " + param + " parameter and " +
					"the server returned its computed product — the template engine evaluated attacker-controlled " +
					"input, confirmed against ground truth (product present in the attack response, absent in the control).",
				Severity:       pipeline.SeverityCritical,
				CVSSScore:      cvssForImpact(pipeline.SeverityCritical),
				AttackCategory: "ssti",
				Confidence:     pipeline.Confidence("high"),
				Target:         epURL,
				ClassifiedAt:   time.Now(),
			}
			engine, ok := a.verifySSTI(ctx, &cf, epURL, param, authHeader)
			if a.probeSink != nil {
				a.probeSink(epURL, ok)
			}
			if !ok {
				continue
			}
			cf.Title = "Server-Side Template Injection (" + engine + ") in parameter " + param
			cf.Evidence = []pipeline.Evidence{{
				Type: "http_response", Content: cf.VerifiedBy, Timestamp: time.Now(),
				Description: "Differential proof: the template expression evaluated to its product in the response",
			}}
			data, _ := json.Marshal(cf)
			_, _ = board.Write(ctx, blackboard.Finding{
				CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
				Target: epURL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
			})
		}
	}
}

// queryParamNames returns the distinct query-parameter names of a URL, in order.
func queryParamNames(rawURL string) []string {
	u, err := url.Parse(rawURL)
	if err != nil || u.RawQuery == "" {
		return nil
	}
	var out []string
	seen := map[string]struct{}{}
	for _, pair := range strings.Split(u.RawQuery, "&") {
		name := pair
		if i := strings.IndexByte(pair, '='); i >= 0 {
			name = pair[:i]
		}
		if name == "" {
			continue
		}
		if n, err := url.QueryUnescape(name); err == nil {
			name = n
		}
		if _, dup := seen[name]; dup {
			continue
		}
		seen[name] = struct{}{}
		out = append(out, name)
	}
	return out
}

// maxAuthBypassEndpoints caps how many protected endpoints one sweep probes.
// Each endpoint fans out to several variant requests, so this bounds the
// campaign's blast radius and wall-clock cost.
const maxAuthBypassEndpoints = 10

// authBypassSweep probes endpoints recon saw return 401/403 for path-based
// access-control bypasses via the injected verifier. A variant that succeeds
// where the control is denied is a proven bypass, published as a VERIFIED
// finding. Runs only under --verify (nil verifier → no-op), is bounded by
// maxAuthBypassEndpoints, and de-duplicates endpoints across calls.
func (a *ExploitAgent) authBypassSweep(ctx context.Context, board blackboard.Board, vars map[string]string) {
	if a.authBypassVerifier == nil {
		return
	}
	authHeader := bearerFromVars(vars) // a low-priv token still makes a 403-bypass a finding
	probes := 0
	for _, epURL := range a.protectedEndpoints(ctx, board) {
		if probes >= maxAuthBypassEndpoints {
			return
		}
		a.seenMu.Lock()
		_, dup := a.seenAuthBypass[epURL]
		if !dup {
			a.seenAuthBypass[epURL] = struct{}{}
		}
		a.seenMu.Unlock()
		if dup {
			continue
		}
		probes++

		cf := pipeline.ClassifiedFinding{
			ID:         uuid.New(),
			CampaignID: a.campaignID,
			Title:      "Access-control bypass (403→200) via path normalization",
			Description: "The swarm requested a protected endpoint through a path-variant trick and the server " +
				"served it while denying the canonical path — a front-end-ACL vs back-end-routing mismatch, " +
				"confirmed against ground truth (attack 2xx vs control denied).",
			Severity:       pipeline.SeverityHigh,
			CVSSScore:      cvssForImpact(pipeline.SeverityHigh),
			AttackCategory: "broken_access_control",
			Confidence:     pipeline.Confidence("high"),
			Target:         epURL,
			ClassifiedAt:   time.Now(),
		}
		technique, ok := a.verifyAuthBypass(ctx, &cf, epURL, authHeader)
		if a.probeSink != nil {
			a.probeSink(epURL, ok)
		}
		if !ok {
			continue
		}
		cf.Title = "Access-control bypass (403→200) via " + technique
		cf.Evidence = []pipeline.Evidence{{
			Type: "http_response", Content: cf.VerifiedBy, Timestamp: time.Now(),
			Description: "Differential proof: the " + technique + " variant is served while the canonical path is denied",
		}}
		data, _ := json.Marshal(cf)
		_, _ = board.Write(ctx, blackboard.Finding{
			CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
			Target: epURL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
		})
	}
}

// protectedEndpoints returns the campaign's discovered endpoint URLs that recon
// saw return 401/403 — the candidates worth a bypass probe — de-duplicated.
func (a *ExploitAgent) protectedEndpoints(ctx context.Context, board blackboard.Board) []string {
	if board == nil {
		return nil
	}
	found, err := board.Query(ctx, blackboard.Predicate{
		Types: []blackboard.FindingType{blackboard.TypeHTTPEndpoint}, Limit: 200,
	})
	if err != nil {
		return nil
	}
	seen := map[string]struct{}{}
	var urls []string
	for _, fnd := range found {
		var ep pipeline.EndpointRecord
		if json.Unmarshal(fnd.Data, &ep) != nil || ep.URL == "" {
			continue
		}
		if ep.StatusCode != 401 && ep.StatusCode != 403 {
			continue
		}
		if _, dup := seen[ep.URL]; dup {
			continue
		}
		seen[ep.URL] = struct{}{}
		urls = append(urls, ep.URL)
	}
	return urls
}

// maxSSRFProbes caps how many out-of-band SSRF probes one sweep fires. Each OOB
// probe sends a request and waits a listen window, so this bounds the campaign's
// blast radius and wall-clock cost.
const maxSSRFProbes = 12

// ssrfSweep probes discovered endpoints whose query parameters look like SSRF
// sinks (DetectSSRFParams), pointing each at an out-of-band callback via the
// injected verifier. A callback is a server-side request forgery proven against
// ground truth; it is published as a VERIFIED finding. Runs only under --verify
// (nil verifier → no-op), is bounded by maxSSRFProbes, and de-duplicates sinks
// across calls so repeated chains don't re-probe the same endpoint/param.
func (a *ExploitAgent) ssrfSweep(ctx context.Context, board blackboard.Board, vars map[string]string) {
	if a.ssrfVerifier == nil {
		return
	}
	authHeader := bearerFromVars(vars) // may be empty — SSRF is often unauthenticated
	probes := 0
	for _, epURL := range a.endpointURLs(ctx, board) {
		for _, param := range exploitpkg.DetectSSRFParams(epURL) {
			if probes >= maxSSRFProbes {
				return
			}
			key := epURL + "|" + param
			a.seenMu.Lock()
			_, dup := a.seenSSRF[key]
			if !dup {
				a.seenSSRF[key] = struct{}{}
			}
			a.seenMu.Unlock()
			if dup {
				continue
			}
			probes++

			cf := pipeline.ClassifiedFinding{
				ID:         uuid.New(),
				CampaignID: a.campaignID,
				Title:      "Server-Side Request Forgery (SSRF) via parameter " + param,
				Description: "The swarm pointed the " + param + " parameter at an out-of-band callback URL and the " +
					"server made the outbound request — confirmed SSRF against infrastructure we control (no model " +
					"judgment, no scripted playbook).",
				Severity:       pipeline.SeverityHigh,
				CVSSScore:      cvssForImpact(pipeline.SeverityHigh),
				AttackCategory: "ssrf",
				Confidence:     pipeline.Confidence("high"),
				Target:         epURL,
				ClassifiedAt:   time.Now(),
			}
			a.verifySSRF(ctx, &cf, epURL, param, authHeader)
			if a.probeSink != nil {
				a.probeSink(epURL, cf.Verified)
			}
			if !cf.Verified {
				continue // OOB never disproves; silence is simply not a finding
			}
			cf.Evidence = []pipeline.Evidence{{
				Type: "oob_interaction", Content: cf.VerifiedBy, Timestamp: time.Now(),
				Description: "Out-of-band callback proving the server dereferenced the sink parameter",
			}}
			data, _ := json.Marshal(cf)
			_, _ = board.Write(ctx, blackboard.Finding{
				CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
				Target: epURL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
			})
		}
	}
}

// endpointURLs returns the campaign's discovered HTTP endpoint URLs from the
// board (all of them, not just API endpoints — an SSRF sink can live on any
// page), de-duplicated and capped.
func (a *ExploitAgent) endpointURLs(ctx context.Context, board blackboard.Board) []string {
	if board == nil {
		return nil
	}
	found, err := board.Query(ctx, blackboard.Predicate{
		Types: []blackboard.FindingType{blackboard.TypeHTTPEndpoint}, Limit: 200,
	})
	if err != nil {
		return nil
	}
	seen := map[string]struct{}{}
	var urls []string
	for _, fnd := range found {
		var ep pipeline.EndpointRecord
		if json.Unmarshal(fnd.Data, &ep) != nil || ep.URL == "" {
			continue
		}
		if _, dup := seen[ep.URL]; dup {
			continue
		}
		seen[ep.URL] = struct{}{}
		urls = append(urls, ep.URL)
	}
	return urls
}

// adaptiveSweep is the runtime-reaction engine: from a completed chain it takes
// the captured session + the object references seen in the responses, and
// replays those ids across the campaign's id-bearing endpoints. A cross-user
// 200 is a BOLA the swarm found on its own. Harvested refs are also published
// as shared OBJECT_REF state. Bounded and best-effort; no session → no sweep.
func (a *ExploitAgent) adaptiveSweep(ctx context.Context, board blackboard.Board, results []*pipeline.ExecutionResult, vars map[string]string) {
	if a.executor == nil {
		return
	}
	authHeader := bearerFromVars(vars)
	if authHeader == "" {
		return // no session captured — nothing to replay as
	}

	// Harvest object refs from every response body (+ captured var values).
	var blob strings.Builder
	for _, r := range results {
		if r != nil {
			blob.WriteString(r.Output)
			blob.WriteByte('\n')
		}
	}
	for _, v := range vars {
		blob.WriteString(v)
		blob.WriteByte(' ')
	}
	refs := exploitpkg.HarvestObjectRefs(blob.String())
	if len(refs) == 0 {
		return
	}

	// Publish harvested refs as shared discovery state (capped).
	refBase, refHalf := a.tun.Lookup(blackboard.TypeObjectRef)
	for i, ref := range refs {
		if i >= 24 {
			break
		}
		data, _ := json.Marshal(ref)
		_, _ = board.Write(ctx, blackboard.Finding{
			CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeObjectRef,
			Target: ref.Value, Data: data, PheromoneBase: refBase, HalfLifeSec: refHalf,
		})
	}

	// Replay the harvested ids across id-bearing endpoints discovered by recon.
	templates := exploitpkg.DeriveBOLATargets(a.apiEndpointURLs(ctx, board))
	if len(templates) == 0 {
		return
	}
	// SweepBOLAWithProbe reports each replay work-unit as it fires, so the live
	// view can render the fan-out. The sweep itself is unchanged.
	hits := a.executor.SweepBOLAWithProbe(ctx, authHeader, templates, refs, a.campaignID,
		func(target string, ok bool) {
			if a.probeSink != nil {
				a.probeSink(target, ok)
			}
		})
	for _, hit := range hits {
		cf := pipeline.ClassifiedFinding{
			ID:         uuid.New(),
			CampaignID: a.campaignID,
			Title:      "Adaptive BOLA: cross-user object access via harvested id",
			Description: "The swarm harvested an object id it does not own from an earlier response and replayed " +
				"it at this endpoint with its own session — the endpoint returned 200 with data. Broken " +
				"object-level authorization, found adaptively (no scripted playbook for this endpoint).",
			Severity:       pipeline.SeverityHigh,
			CVSSScore:      cvssForImpact(pipeline.SeverityHigh),
			AttackCategory: "api_business_logic",
			Confidence:     pipeline.Confidence("high"),
			Target:         hit.URL,
			ClassifiedAt:   time.Now(),
			Evidence: []pipeline.Evidence{{
				Type: "http_response", Content: hit.Evidence, Timestamp: time.Now(),
				Description: "Cross-user 200 response to a replayed object id",
			}},
		}
		// --verify: re-prove the BOLA with an external differential oracle. A
		// proven hit is stamped VERIFIED (ground truth, not the swarm's own say-so)
		// which drives its attack-graph edge to 1.0 and a report badge.
		a.verifyBOLA(ctx, &cf, hit.URL, authHeader)
		data, _ := json.Marshal(cf)
		_, _ = board.Write(ctx, blackboard.Finding{
			CampaignID: a.campaignID, AgentName: a.Name(), Type: blackboard.TypeMisconfig,
			Target: hit.URL, Data: data, PheromoneBase: 1.0, HalfLifeSec: 3600,
		})
	}
}

// bearerFromVars finds a captured session token among the chain variables and
// returns it as an Authorization header value. Recognizes common capture names
// and, failing that, any value shaped like a JWT (three dot-separated parts).
func bearerFromVars(vars map[string]string) string {
	for _, k := range []string{"jwt", "token", "tok", "access_token", "accesstoken", "auth", "bearer", "session"} {
		if v := strings.TrimSpace(vars[k]); v != "" {
			return "Bearer " + v
		}
	}
	for k, v := range vars {
		if k == "nonce" || k == "nonce_num" {
			continue
		}
		if strings.Count(v, ".") == 2 && len(v) > 20 {
			return "Bearer " + v
		}
	}
	return ""
}

// apiEndpointURLs returns the campaign's discovered API endpoint URLs from the
// board, for the adaptive BOLA sweep to derive id-templated targets from.
func (a *ExploitAgent) apiEndpointURLs(ctx context.Context, board blackboard.Board) []string {
	if board == nil {
		return nil
	}
	found, err := board.Query(ctx, blackboard.Predicate{
		Types: []blackboard.FindingType{blackboard.TypeHTTPEndpoint}, Limit: 200,
	})
	if err != nil {
		return nil
	}
	seen := map[string]struct{}{}
	var urls []string
	for _, fnd := range found {
		var ep pipeline.EndpointRecord
		if json.Unmarshal(fnd.Data, &ep) != nil || ep.URL == "" || !isAPIEndpoint(ep) {
			continue
		}
		if _, dup := seen[ep.URL]; dup {
			continue
		}
		seen[ep.URL] = struct{}{}
		urls = append(urls, ep.URL)
	}
	return urls
}

// publishPlaybookFinding writes a report-ready ClassifiedFinding for a
// successful playbook, so a proven exploit appears in the report's Findings
// section (which is built from CVE/misconfig findings) rather than only in the
// attack narrative.
func (a *ExploitAgent) publishPlaybookFinding(ctx context.Context, board blackboard.Board, target string, path pipeline.AttackPath, proof string, vars map[string]string) {
	sev := pipeline.Severity(strings.ToLower(path.ExpectedImpact))
	switch sev {
	case pipeline.SeverityCritical, pipeline.SeverityHigh, pipeline.SeverityMedium, pipeline.SeverityLow:
	default:
		sev = pipeline.SeverityHigh
	}
	// Use the proof step's own endpoint as the finding target rather than the
	// campaign base URL: distinct business-logic findings hit distinct
	// endpoints, so this keeps them from being collapsed together by the
	// report's same-target dedup (while genuine same-URL duplicates still
	// collapse).
	findingTarget := target
	if u := lastStepURL(path.Steps); u != "" {
		findingTarget = u
	}
	cf := pipeline.ClassifiedFinding{
		ID:             uuid.New(),
		CampaignID:     a.campaignID,
		Title:          path.Name,
		Description:    path.Description + "\n\nProven by executing the verified attack chain against the live target.",
		Severity:       sev,
		CVSSScore:      cvssForImpact(sev),
		AttackCategory: "api_business_logic",
		Confidence:     pipeline.Confidence("high"),
		Target:         findingTarget,
		ClassifiedAt:   time.Now(),
		Evidence: []pipeline.Evidence{{
			Type:        "http_response",
			Content:     proof,
			Timestamp:   time.Now(),
			Description: "Response proving the cross-user access-control bypass",
		}},
	}
	// --verify: if this chain is a mass-assignment, re-prove it with an external
	// differential oracle. A proven hit is stamped VERIFIED (ground truth, not
	// the swarm's own say-so), driving its attack-graph edge to 1.0 and a report
	// badge. A no-op for non-mass-assignment chains or without --verify.
	a.verifyMassAssignment(ctx, &cf, path, vars)
	data, _ := json.Marshal(cf)
	// Publish above the report's default publish threshold (0.5) so a proven
	// finding is never filtered out.
	_, _ = board.Write(ctx, blackboard.Finding{
		CampaignID:    a.campaignID,
		AgentName:     a.Name(),
		Type:          blackboard.TypeMisconfig,
		Target:        target,
		Data:          data,
		PheromoneBase: 1.0,
		HalfLifeSec:   3600,
	})
}

// lastStepURL extracts the --url value of the chain's last non-empty step (the
// proof step), so a proven finding is attributed to the endpoint it actually
// hit. Returns "" when no URL is found.
func lastStepURL(steps []pipeline.AttackStep) string {
	for i := len(steps) - 1; i >= 0; i-- {
		if steps[i].Command == "" {
			continue
		}
		fields := strings.Fields(steps[i].Command)
		for j, f := range fields {
			if (f == "--url" || f == "-u") && j+1 < len(fields) {
				return fields[j+1]
			}
		}
		return "" // last real step had no --url
	}
	return ""
}

// cvssForImpact maps a coarse impact band to a representative CVSS base score
// for a proven business-logic finding.
func cvssForImpact(s pipeline.Severity) float64 {
	switch s {
	case pipeline.SeverityCritical:
		return 9.1
	case pipeline.SeverityHigh:
		return 8.2
	case pipeline.SeverityMedium:
		return 5.5
	default:
		return 3.5
	}
}

// handleAPIEndpoint decides whether a discovered endpoint is worth an API
// business-logic attack and, if so, synthesizes a target finding (enriched
// with sibling endpoints for auth context) and runs the exploit path.
func (a *ExploitAgent) handleAPIEndpoint(ctx context.Context, f blackboard.Finding, board blackboard.Board) error {
	var ep pipeline.EndpointRecord
	if err := json.Unmarshal(f.Data, &ep); err != nil {
		return nil // not an endpoint payload we understand; nothing to do
	}
	if !isAPIEndpoint(ep) {
		return nil
	}

	// Deduplicate: /vehicle/42/location and /vehicle/43/location are the same
	// attack, so collapse numeric path segments before the seen-check.
	key := normalizeEndpoint(ep.Method, ep.URL)
	a.seenMu.Lock()
	if _, dup := a.seenAPI[key]; dup {
		a.seenMu.Unlock()
		return nil
	}
	a.seenAPI[key] = struct{}{}
	a.seenMu.Unlock()

	// Hard cost ceiling on distinct API targets per campaign.
	if atomic.AddInt64(&a.apiPlanCount, 1) > int64(a.maxAPITargets) {
		return nil
	}

	cf := pipeline.ClassifiedFinding{
		ID:             uuid.New(),
		CampaignID:     a.campaignID,
		Title:          fmt.Sprintf("API endpoint: %s %s", methodOrGet(ep.Method), ep.URL),
		Description:    a.apiTargetDescription(ctx, ep, board),
		Severity:       pipeline.Severity("medium"),
		AttackCategory: "api_endpoint",
		Confidence:     pipeline.Confidence("low"),
		Target:         ep.URL,
		ClassifiedAt:   time.Now(),
	}
	return a.runExploit(ctx, board, cf)
}

// apiTargetDescription builds the planner's context for an API attack: the
// endpoint under test plus the campaign's other discovered API endpoints, with
// likely authentication routes called out so the LLM can build a
// login → capture-token → replay chain.
func (a *ExploitAgent) apiTargetDescription(ctx context.Context, ep pipeline.EndpointRecord, board blackboard.Board) string {
	var b strings.Builder
	fmt.Fprintf(&b, "Target API endpoint: %s %s", methodOrGet(ep.Method), ep.URL)
	if len(ep.Parameters) > 0 {
		fmt.Fprintf(&b, "\nParameters: %s", strings.Join(ep.Parameters, ", "))
	}
	if ep.Notes != "" {
		fmt.Fprintf(&b, "\nNotes: %s", ep.Notes)
	}
	b.WriteString("\n\nAttempt API business-logic attacks with httpreq: broken object-level authorization (BOLA/IDOR) by swapping object ids, mass assignment by adding privileged fields to the body, and broken authentication / JWT abuse. If the endpoint needs auth, first authenticate at a login route below and --capture the token.")

	auth, others := a.siblingEndpoints(ctx, board, ep.URL)
	if len(auth) > 0 {
		fmt.Fprintf(&b, "\n\nLikely authentication endpoints:\n- %s", strings.Join(auth, "\n- "))
	}
	if len(others) > 0 {
		fmt.Fprintf(&b, "\n\nOther discovered API endpoints (for chaining / object-id sources):\n- %s", strings.Join(others, "\n- "))
	}
	return b.String()
}

// siblingEndpoints returns the campaign's other API endpoints, split into
// likely-auth routes and the rest, each list capped so the prompt stays small.
func (a *ExploitAgent) siblingEndpoints(ctx context.Context, board blackboard.Board, self string) (auth, others []string) {
	if board == nil {
		return nil, nil
	}
	found, err := board.Query(ctx, blackboard.Predicate{
		Types: []blackboard.FindingType{blackboard.TypeHTTPEndpoint},
		Limit: 200,
	})
	if err != nil {
		return nil, nil
	}
	const authCap, otherCap = 8, 24
	seen := map[string]struct{}{}
	for _, fnd := range found {
		var ep pipeline.EndpointRecord
		if json.Unmarshal(fnd.Data, &ep) != nil || ep.URL == "" || ep.URL == self {
			continue
		}
		if !isAPIEndpoint(ep) {
			continue
		}
		line := fmt.Sprintf("%s %s", methodOrGet(ep.Method), ep.URL)
		if _, dup := seen[line]; dup {
			continue
		}
		seen[line] = struct{}{}
		if isAuthEndpoint(ep.URL) {
			if len(auth) < authCap {
				auth = append(auth, line)
			}
		} else if len(others) < otherCap {
			others = append(others, line)
		}
	}
	return auth, others
}

// runExploit builds an attack plan for a single classified finding, publishes
// the chain, and (unless dry-run) executes it as a stateful chain so captured
// tokens/ids thread across steps. Each step result becomes its own finding.
func (a *ExploitAgent) runExploit(ctx context.Context, board blackboard.Board, cf pipeline.ClassifiedFinding) error {
	set := pipeline.ClassifiedFindingSet{
		CampaignID: a.campaignID,
		Findings:   []pipeline.ClassifiedFinding{cf},
	}
	// On expose les outils reellement autorises par l'executor (garde nil ->
	// slice vide) pour contraindre le planificateur a l'allowlist.
	var allowedTools []string
	if a.executor != nil {
		allowedTools = a.executor.AllowedToolNames()
	}
	plan, err := a.exploit.BuildPlan(ctx, set, a.objective, allowedTools)
	if err != nil {
		return fmt.Errorf("build plan: %w", err)
	}
	if plan == nil || len(plan.Paths) == 0 {
		return nil
	}

	// Adaptive attack-path scoring (opt-in): when a Jev scorer is wired, run the
	// full adaptive loop — score candidate strategies against live state, pursue
	// the best, grade pheromone by the score + actual success, re-score on
	// failure with the fresh outcomes, and fall through to the next-best. The
	// default (no scorer) keeps the original single-path flow below unchanged.
	if a.scorer != nil {
		return a.runAdaptive(ctx, board, cf, plan)
	}

	// Always publish the chain so other agents (and the final report) can see it.
	// Carry the planner's reasoning alongside the path so the live UIs can show
	// WHY the swarm chained these vulns, not just the steps.
	chainData, _ := json.Marshal(pipeline.ExploitChainWire{AttackPath: plan.Paths[0], Reasoning: plan.Reasoning})
	chainBase, chainHalf := a.tun.Lookup(blackboard.TypeExploitChain)
	chainID, _ := board.Write(ctx, blackboard.Finding{
		CampaignID:    a.campaignID,
		AgentName:     a.Name(),
		Type:          blackboard.TypeExploitChain,
		Target:        cf.Target,
		Data:          chainData,
		PheromoneBase: chainBase,
		HalfLifeSec:   chainHalf,
	})

	if a.dryRun || a.executor == nil {
		return nil
	}

	// Execute the top path as a chain so state (captured tokens/ids) threads
	// across steps — the authenticated API attacks depend on.
	results := a.executeAndPublish(ctx, board, chainID, plan.Paths[0].Steps, cf.Target, 1.0)

	// Basic LLM feedback loop (the seed of the "self-correcting attacks"
	// roadmap pillar): if the improvised chain wholly failed, feed the last
	// result back to the planner for ONE adjusted retry. Bounded to a single
	// retry — no recursion, no loop — so it can neither spin nor blow the
	// budget, and it only touches LLM-improvised chains: deterministic
	// playbooks run via handlePlaybook and never reach here.
	if !anySuccess(results) && len(results) > 0 {
		feedback := *results[len(results)-1]
		// Capture the original signature BEFORE AdaptPlan: it mutates the plan
		// in place and returns the same pointer, so comparing after the call
		// would compare the adjusted plan against itself.
		origSig := chainSignature(plan.Paths[0].Steps)
		adjusted, aerr := a.exploit.AdaptPlan(ctx, plan, feedback)
		if aerr == nil && adjusted != nil && len(adjusted.Paths) > 0 &&
			chainSignature(adjusted.Paths[0].Steps) != origSig {
			data, _ := json.Marshal(pipeline.ExploitChainWire{AttackPath: adjusted.Paths[0], Reasoning: adjusted.Reasoning})
			base2, half2 := a.tun.Lookup(blackboard.TypeExploitChain)
			retryChainID, _ := board.Write(ctx, blackboard.Finding{
				CampaignID:    a.campaignID,
				AgentName:     a.Name(),
				Type:          blackboard.TypeExploitChain,
				Target:        cf.Target,
				Data:          data,
				PheromoneBase: base2,
				HalfLifeSec:   half2,
			})
			a.executeAndPublish(ctx, board, retryChainID, adjusted.Paths[0].Steps, cf.Target, 1.0)
		}
	}
	return nil
}

// executeAndPublish runs a chain and writes one EXPLOIT_RESULT per step,
// returning the results. Successful steps get max pheromone so downstream
// agents (and the report) definitely notice them.
// executeAndPublish runs a chain and publishes each step's result. score is the
// strategy's adaptive score in [0,1] (1.0 for non-adaptive callers): a
// successful step lays down pheromone graded by that score, so a working step
// on a higher-scored strategy reinforces more strongly — the "pheromone
// signature" that concentrates the swarm on strategies that actually land.
func (a *ExploitAgent) executeAndPublish(ctx context.Context, board blackboard.Board, chainID uuid.UUID, steps []pipeline.AttackStep, target string, score float64) []*pipeline.ExecutionResult {
	results, _ := a.executor.ExecuteChain(ctx, steps, a.campaignID)

	// Pair each result back to its step name. ExecuteChain skips empty-command
	// steps, so walk the non-empty steps in order.
	stepNames := make([]string, 0, len(steps))
	for _, s := range steps {
		if s.Command != "" {
			stepNames = append(stepNames, s.Name)
		}
	}
	for i, res := range results {
		name := ""
		if i < len(stepNames) {
			name = stepNames[i]
		}
		resData, _ := json.Marshal(map[string]any{
			"chain_id": chainID, "step": name, "result": res,
		})
		pheromone, halfLife := a.tun.Lookup(blackboard.TypeExploitResult)
		if res.Success {
			// Graded by the strategy score: 0.6 (unscored/low) → 1.0 (top).
			pheromone = 0.6 + 0.4*clamp01(score)
		}
		_, _ = board.Write(ctx, blackboard.Finding{
			CampaignID:    a.campaignID,
			AgentName:     a.Name(),
			Type:          blackboard.TypeExploitResult,
			Target:        target,
			Data:          resData,
			PheromoneBase: pheromone,
			HalfLifeSec:   halfLife,
		})
	}
	return results
}

// anySuccess reports whether any step in the chain succeeded.
func anySuccess(results []*pipeline.ExecutionResult) bool {
	for _, r := range results {
		if r != nil && r.Success {
			return true
		}
	}
	return false
}

// maxAdaptiveAttempts bounds the adaptive loop: pursue the best-scored strategy,
// and on failure try one re-scored next-best, before the last-resort LLM retry.
// Kept small so a run can neither spin nor blow the budget.
const maxAdaptiveAttempts = 2

// runAdaptive is the adaptive attack-path scoring loop, active only when a Jev
// scorer is wired (--jev-adaptive). It ranks the planner's candidate paths
// against live state, pursues the best, grades pheromone by the strategy score
// AND actual success (the "pheromone signature"), and on a wholly-failed attempt
// re-scores the remaining candidates with the fresh outcome data before falling
// through to the next-best. If every scored candidate fails it ends with the
// same single LLM-adjusted retry the default path uses.
func (a *ExploitAgent) runAdaptive(ctx context.Context, board blackboard.Board, cf pipeline.ClassifiedFinding, plan *pipeline.AttackPlan) error {
	// Rank only the runnable candidate strategies — the command-less conceptual
	// rule chains are for the report narrative, not for the adaptive execution
	// loop. Fall back to all paths if none are runnable (never rank nothing).
	candidates := executablePaths(plan.Paths)
	if len(candidates) == 0 {
		candidates = plan.Paths
	}
	ranked := a.scorer.RankPaths(ctx, a.liveState(cf), candidates)
	if len(ranked) == 0 {
		return nil
	}
	if a.scoreSink != nil {
		a.scoreSink(ranked)
	}

	var lastResults []*pipeline.ExecutionResult
	for attempt := 0; attempt < maxAdaptiveAttempts && attempt < len(ranked); attempt++ {
		sp := ranked[attempt]

		// Publish the chain; pheromone graded by the strategy score so the
		// best-scored strategy is the strongest trail before we even run it.
		chainData, _ := json.Marshal(pipeline.ExploitChainWire{AttackPath: sp.Path, Reasoning: plan.Reasoning})
		chainBase, chainHalf := a.tun.Lookup(blackboard.TypeExploitChain)
		chainID, _ := board.Write(ctx, blackboard.Finding{
			CampaignID:    a.campaignID,
			AgentName:     a.Name(),
			Type:          blackboard.TypeExploitChain,
			Target:        cf.Target,
			Data:          chainData,
			PheromoneBase: chainBase * (0.5 + 0.5*clamp01(sp.Score)),
			HalfLifeSec:   chainHalf,
		})

		if a.dryRun || a.executor == nil {
			return nil
		}

		results := a.executeAndPublish(ctx, board, chainID, sp.Path.Steps, cf.Target, sp.Score)
		lastResults = results
		if anySuccess(results) {
			return nil // committed to the winning strategy
		}

		// Real-time feedback: re-score the remaining candidates using the fresh
		// failure outcomes as updated live state, then pursue the next-best.
		if attempt+1 < maxAdaptiveAttempts && attempt+1 < len(ranked) {
			remaining := make([]pipeline.AttackPath, 0, len(ranked)-attempt-1)
			for _, r := range ranked[attempt+1:] {
				remaining = append(remaining, r.Path)
			}
			reRanked := a.scorer.RankPaths(ctx, a.liveStateWithResults(cf, results), remaining)
			for i, rp := range reRanked {
				ranked[attempt+1+i] = rp
			}
			if a.scoreSink != nil {
				a.scoreSink(ranked)
			}
		}
	}

	// Every scored candidate failed → last-resort single LLM-adjusted retry.
	if len(lastResults) > 0 && !anySuccess(lastResults) {
		feedback := *lastResults[len(lastResults)-1]
		origSig := chainSignature(plan.Paths[0].Steps)
		adjusted, aerr := a.exploit.AdaptPlan(ctx, plan, feedback)
		if aerr == nil && adjusted != nil && len(adjusted.Paths) > 0 &&
			chainSignature(adjusted.Paths[0].Steps) != origSig {
			data, _ := json.Marshal(pipeline.ExploitChainWire{AttackPath: adjusted.Paths[0], Reasoning: adjusted.Reasoning})
			base2, half2 := a.tun.Lookup(blackboard.TypeExploitChain)
			retryChainID, _ := board.Write(ctx, blackboard.Finding{
				CampaignID:    a.campaignID,
				AgentName:     a.Name(),
				Type:          blackboard.TypeExploitChain,
				Target:        cf.Target,
				Data:          data,
				PheromoneBase: base2,
				HalfLifeSec:   half2,
			})
			a.executeAndPublish(ctx, board, retryChainID, adjusted.Paths[0].Steps, cf.Target, 1.0)
		}
	}
	return nil
}

// liveStateWithResults augments the live state with the most recent attempt's
// per-step outcomes so Jev re-scores against what actually just happened.
func (a *ExploitAgent) liveStateWithResults(cf pipeline.ClassifiedFinding, results []*pipeline.ExecutionResult) string {
	var b strings.Builder
	b.WriteString(a.liveState(cf))
	b.WriteString("\nMost recent attempt — step outcomes:")
	for _, r := range results {
		if r == nil {
			continue
		}
		status := "failed"
		if r.Success {
			status = "succeeded"
		}
		cmd := r.CommandExecuted
		if len(cmd) > 80 {
			cmd = cmd[:80]
		}
		fmt.Fprintf(&b, "\n  - %s: %s", cmd, status)
	}
	return b.String()
}

// executablePaths keeps only paths with at least one runnable (non-empty
// command) step. The adaptive loop scores and runs real strategies, not the
// command-less conceptual rule chains the path builder emits for the report.
func executablePaths(paths []pipeline.AttackPath) []pipeline.AttackPath {
	out := make([]pipeline.AttackPath, 0, len(paths))
	for _, p := range paths {
		for _, s := range p.Steps {
			if strings.TrimSpace(s.Command) != "" {
				out = append(out, p)
				break
			}
		}
	}
	return out
}

// clamp01 bounds a score to [0,1] so a stray value can't distort pheromone.
func clamp01(v float64) float64 {
	if v < 0 {
		return 0
	}
	if v > 1 {
		return 1
	}
	return v
}

// chainSignature is a cheap identity for a chain's commands, used to skip a
// pointless retry when AdaptPlan hands back the same steps (e.g. because the
// LLM call failed and it returned the original plan unchanged).
func chainSignature(steps []pipeline.AttackStep) string {
	var b strings.Builder
	for _, s := range steps {
		b.WriteString(s.Command)
		b.WriteByte('\n')
	}
	return b.String()
}

// staticAssetSuffixes are file extensions that are attack-surface noise, never
// an API business-logic target.
var staticAssetSuffixes = []string{
	".js", ".css", ".map", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
	".woff", ".woff2", ".ttf", ".eot", ".webp", ".mp4", ".pdf",
}

// isAPIEndpoint reports whether an endpoint is worth an API business-logic
// attack: it looks like an API route (path contains /api/ or /rest/, or a
// non-GET method, or the crawler flagged it interesting) and is not a static
// asset.
func isAPIEndpoint(ep pipeline.EndpointRecord) bool {
	if ep.URL == "" {
		return false
	}
	lower := strings.ToLower(ep.URL)
	if u, err := url.Parse(ep.URL); err == nil {
		path := strings.ToLower(u.Path)
		for _, ext := range staticAssetSuffixes {
			if strings.HasSuffix(path, ext) {
				return false
			}
		}
	}
	if strings.Contains(lower, "/api/") || strings.Contains(lower, "/rest/") {
		return true
	}
	if m := strings.ToUpper(ep.Method); m != "" && m != "GET" && m != "HEAD" {
		return true
	}
	return ep.Interesting
}

// isAuthEndpoint reports whether a URL looks like an authentication route,
// so it can be surfaced to the planner as a token source.
func isAuthEndpoint(u string) bool {
	l := strings.ToLower(u)
	for _, kw := range []string{"login", "signin", "auth", "token", "oauth", "signup", "register", "session"} {
		if strings.Contains(l, kw) {
			return true
		}
	}
	return false
}

// normalizeEndpoint builds a dedup key for an endpoint by dropping the query
// string and collapsing numeric path segments (object ids) to ":id", so
// /vehicle/42/location and /vehicle/43/location map to one attack target.
func normalizeEndpoint(method, raw string) string {
	m := methodOrGet(method)
	u, err := url.Parse(raw)
	if err != nil {
		return m + " " + raw
	}
	segs := strings.Split(u.Path, "/")
	for i, s := range segs {
		if s == "" {
			continue
		}
		if isNumericIsh(s) {
			segs[i] = ":id"
		}
	}
	return m + " " + u.Host + strings.Join(segs, "/")
}

// isNumericIsh reports whether a path segment is a numeric or uuid-like id.
func isNumericIsh(s string) bool {
	digits := 0
	for _, r := range s {
		switch {
		case r >= '0' && r <= '9':
			digits++
		case r == '-' || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F'):
			// hex / uuid separators
		default:
			return false
		}
	}
	return digits > 0
}

func methodOrGet(m string) string {
	if m == "" {
		return "GET"
	}
	return strings.ToUpper(m)
}
