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/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

	// 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)
}

// 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{}),
	}
}

// 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)
	}

	// 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)
	return nil
}

// 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",
			}},
		}
		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) {
	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",
		}},
	}
	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.
	chainData, _ := json.Marshal(plan.Paths[0])
	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(adjusted.Paths[0])
			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(sp.Path)
		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(adjusted.Paths[0])
			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)
}
