// expedition-sim drives synthetic players through expeditions for batch
// analysis. Re-uses production plugin paths against a fresh sqlite DB so
// outcomes mirror what live players hit.
//
// Single run:
//   expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
//                  [-bank 1000] [-cap 50] [-log] [-data DIR]
//
// Matrix mode (cartesian sweep over classes × levels × zones × N runs,
// one JSON object per stdout line, log suppressed by default):
//   expedition-sim -matrix -classes fighter,mage -levels 5,10 \
//                  -zones goblin_warrens,wolf_den -runs 20
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"os/exec"
	"runtime"
	"strconv"
	"strings"
	"sync"

	"gogobee/internal/plugin"

	"maunium.net/go/mautrix/id"
)

func main() {
	var (
		class   = flag.String("class", "fighter", "DnD class id (single-run mode)")
		level   = flag.Int("level", 5, "character level (single-run mode)")
		zone    = flag.String("zone", "goblin_warrens", "zone id (single-run mode)")
		bank    = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting")
		cap     = flag.Int("cap", 50, "max autopilot bursts per expedition (each = up to autopilotRoomCap rooms)")
		days    = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
		dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
		userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
		logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")

		matrix  = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
		classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
		levels  = flag.String("levels", "", "comma-separated levels (matrix mode)")
		zones   = flag.String("zones", "", "comma-separated zone ids (matrix mode)")
		runs    = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)")

		trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps")

		petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")

		jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
	)
	flag.Parse()

	if *petLevel < 0 || *petLevel > 10 {
		fail("pet-level must be 0-10, got", *petLevel)
	}

	plugin.SetSimIncludeTrace(*trace)
	plugin.SetSimPetLevel(*petLevel)

	if *matrix {
		// Matrix default: drop log to keep stdout manageable; explicit
		// -log=true overrides.
		includeLog := false
		// Flag.Lookup tells us whether the user explicitly set -log.
		flag.Visit(func(f *flag.Flag) {
			if f.Name == "log" {
				includeLog = *logFlag
			}
		})
		runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel)
		return
	}

	runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag)
}

func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool) {
	dir := dataDir
	if dir == "" {
		var err error
		dir, err = os.MkdirTemp("", "expedition-sim-")
		if err != nil {
			fail("mkdir temp:", err)
		}
		defer os.RemoveAll(dir)
	}

	res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days)
	if err != nil {
		if res != nil {
			if !includeLog {
				res.Log = nil
			}
			emitIndented(res)
		}
		fail("run:", err)
	}
	if !includeLog {
		res.Log = nil
	}
	emitIndented(res)
}

// matrixJob is one (class, level, zone, replicate-index) cell of the
// matrix sweep. Each job is run by a worker as a single-run subprocess so
// it gets its own SQLite handle — the plugin package's db.* globals
// preclude in-process parallelism.
type matrixJob struct {
	class string
	level int
	zone  string
	rep   int
}

func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel int) {
	cs := splitNonEmpty(classes)
	ls := parseLevels(levels)
	zs := splitNonEmpty(zones)
	if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || runs <= 0 {
		fail("matrix mode requires non-empty -classes, -levels, -zones and runs > 0")
	}
	if jobs <= 0 {
		jobs = runtime.NumCPU()
	}
	exe, err := os.Executable()
	if err != nil {
		fail("os.Executable:", err)
	}

	work := make([]matrixJob, 0, len(cs)*len(ls)*len(zs)*runs)
	for _, c := range cs {
		for _, lv := range ls {
			for _, z := range zs {
				for r := 0; r < runs; r++ {
					work = append(work, matrixJob{class: c, level: lv, zone: z, rep: r})
				}
			}
		}
	}

	workCh := make(chan matrixJob)
	resCh := make(chan *plugin.SimResult, len(work))
	var wg sync.WaitGroup
	for i := 0; i < jobs; i++ {
		wg.Add(1)
		go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel)
	}
	go func() {
		for _, j := range work {
			workCh <- j
		}
		close(workCh)
	}()
	go func() {
		wg.Wait()
		close(resCh)
	}()

	enc := json.NewEncoder(os.Stdout)
	for r := range resCh {
		_ = enc.Encode(r)
	}
}

func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel int) {
	defer wg.Done()
	for j := range in {
		uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
		dir, err := os.MkdirTemp("", "expedition-sim-")
		if err != nil {
			out <- &plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
			continue
		}
		args := []string{
			"-class", j.class,
			"-level", strconv.Itoa(j.level),
			"-zone", j.zone,
			"-bank", strconv.FormatFloat(bank, 'f', -1, 64),
			"-cap", strconv.Itoa(cap),
			"-days", strconv.Itoa(days),
			"-data", dir,
			"-user", uid,
			fmt.Sprintf("-log=%t", includeLog),
			fmt.Sprintf("-pet-level=%d", petLevel),
		}
		if trace {
			args = append(args, "-trace")
		}
		cmd := exec.Command(exe, args...)
		var stderrBuf bytes.Buffer
		cmd.Stderr = &stderrBuf
		stdout, runErr := cmd.Output()
		var res plugin.SimResult
		jerr := json.Unmarshal(stdout, &res)
		if jerr != nil {
			res = plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
		}
		if runErr != nil && res.Outcome == "" {
			res.Outcome = "halted"
		}
		// Surface subprocess failures: parse error with non-empty stdout
		// (corrupted JSON) and non-zero exits both get a stderr dump so the
		// user sees the underlying cause instead of just a halted row.
		if jerr != nil || runErr != nil {
			fmt.Fprintf(os.Stderr, "sim cell %s halted: runErr=%v jerr=%v\n", uid, runErr, jerr)
			if stderrBuf.Len() > 0 {
				fmt.Fprintf(os.Stderr, "  child stderr:\n%s\n", stderrBuf.String())
			}
			if jerr != nil && len(stdout) > 0 {
				snip := stdout
				if len(snip) > 200 {
					snip = snip[:200]
				}
				fmt.Fprintf(os.Stderr, "  child stdout (first 200B): %q\n", snip)
			}
		}
		if !includeLog {
			res.Log = nil
		}
		out <- &res
		_ = os.RemoveAll(dir)
	}
}


func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int) (*plugin.SimResult, error) {
	runner, err := plugin.NewSimRunner(dataDir)
	if err != nil {
		return nil, fmt.Errorf("init runner: %w", err)
	}
	defer runner.Close()

	if _, err := runner.BuildCharacter(uid, class, level); err != nil {
		return nil, fmt.Errorf("build character: %w", err)
	}
	runner.Euro.Credit(uid, bank, "expedition-sim bankroll")
	return runner.RunExpedition(uid, zone, cap, days)
}

func emitIndented(res *plugin.SimResult) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	_ = enc.Encode(res)
}

func splitNonEmpty(s string) []string {
	parts := strings.Split(s, ",")
	out := parts[:0]
	for _, p := range parts {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}

func parseLevels(s string) []int {
	var out []int
	for _, p := range splitNonEmpty(s) {
		n, err := strconv.Atoi(p)
		if err != nil {
			fail("bad level:", p)
		}
		out = append(out, n)
	}
	return out
}

func fail(args ...interface{}) {
	fmt.Fprintln(os.Stderr, args...)
	os.Exit(1)
}
