package runlive_test

import (
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/event"
	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/orchestrator"
	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/runlive"
	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/store"
	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/transport"
)

// runScenario is the shared harness for all three scenarios. Each test
// supplies an org yaml + prompt + expectations.
type scenario struct {
	name        string
	orgYAML     string
	prompt      string
	wantEvents  []event.Kind
	wantNoEvent []event.Kind
	wantArtPath string // a substring an artifact path must contain
}

func runScenario(t *testing.T, sc scenario) {
	t.Helper()
	tmp := t.TempDir()
	st, err := store.Open(filepath.Join(tmp, "harness.db"))
	if err != nil {
		t.Fatal(err)
	}
	defer st.Close()
	bus := event.NewBus(st)
	q := transport.New(st, bus)
	orch := orchestrator.New(st, bus)

	sub, cancel := bus.Subscribe(4096)
	defer cancel()
	events := []event.Event{}
	done := make(chan struct{})
	go func() {
		defer close(done)
		for ev := range sub {
			events = append(events, ev)
		}
	}()

	root := repoRoot(t)
	orgPath := filepath.Join(root, ".td", "orgs", sc.orgYAML)

	// Snapshot any pre-existing scratch files left by earlier live-server
	// smokes. The post-run check excludes these so only files this test
	// creates can fail the leak assertion.
	preExisting := map[string]bool{}
	for _, dir := range []string{"client", "server-go/internal", ".td/demo-project"} {
		for k, v := range snapshotScratch(filepath.Join(root, dir)) {
			preExisting[k] = v
		}
	}

	// Sandbox the test: copy .td/demo-project/ into a temp dir and point
	// ProjectRoot there so agent writes don't leak into the production
	// repo's .td/demo-project/. This is the production-grade pattern —
	// each run gets an isolated demo project.
	testRoot := filepath.Join(tmp, "project")
	_ = os.MkdirAll(testRoot, 0o755)
	if err := copyDir(filepath.Join(root, ".td", "demo-project"),
		filepath.Join(testRoot, ".td", "demo-project")); err != nil {
		t.Fatalf("copy demo-project: %v", err)
	}

	ctx, ctxCancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer ctxCancel()

	res, err := runlive.Run(ctx, st, bus, q, orch, runlive.Config{
		OrgPath:      orgPath,
		Prompt:       sc.prompt,
		RuntimeMode:  "scripted",
		MaxWall:      10 * time.Second,
		StallTimeout: 8 * time.Second,
		ProjectRoot:  testRoot,
	})
	if err != nil {
		t.Fatalf("[%s] runlive.Run: %v", sc.name, err)
	}
	cancel()
	<-done

	if res.Status != "completed" {
		t.Errorf("[%s] run status = %s (kill=%s), want completed", sc.name, res.Status, res.KillReason)
	}
	if res.TasksDone < 1 {
		t.Errorf("[%s] tasks_done = %d, want >=1 (total=%d)", sc.name, res.TasksDone, res.TasksTotal)
	}

	seen := map[event.Kind]int{}
	for _, ev := range events {
		seen[ev.Kind]++
	}
	for _, k := range sc.wantEvents {
		if seen[k] == 0 {
			t.Errorf("[%s] missing required event: %s", sc.name, k)
		}
	}
	for _, k := range sc.wantNoEvent {
		if seen[k] > 0 {
			t.Errorf("[%s] unexpected event: %s (count=%d)", sc.name, k, seen[k])
		}
	}

	// Verify at least one artifact path contains the expected substring.
	if sc.wantArtPath != "" {
		rows, err := st.DB().Query(`SELECT path, IFNULL(task_id,'') FROM artifacts`)
		if err == nil {
			defer rows.Close()
			found := false
			gotTask := 0
			gotTotal := 0
			for rows.Next() {
				var p, tid string
				_ = rows.Scan(&p, &tid)
				gotTotal++
				if tid != "" {
					gotTask++
				}
				if strings.Contains(p, sc.wantArtPath) {
					found = true
				}
			}
			if !found {
				t.Errorf("[%s] no artifact path contained %q", sc.name, sc.wantArtPath)
			}
			// V40: every write_file artifact must record its task_id so
			// the auto-evaluator's "most recent artifact for this task"
			// lookup works. Allow up to one un-linked artifact (e.g.
			// system-generated state files).
			if gotTotal > 0 && gotTask == 0 {
				t.Errorf("[%s] artifacts table has %d rows but none have task_id set — evaluator lookup will return empty", sc.name, gotTotal)
			}
		}
	}

	// Production-grade isolation check: no NEW scratch files appeared in
	// the real repo. The sandbox + ProjectRoot must contain everything.
	// Pre-existing pollution from earlier live smokes is excluded.
	leak := false
	for _, forbid := range []string{"client", "server-go/internal", ".td/demo-project"} {
		full := filepath.Join(root, forbid)
		if !checkUntouched(t, full, preExisting) {
			leak = true
		}
	}
	if leak {
		t.Errorf("[%s] sandbox leak detected — agent writes escaped the temp ProjectRoot", sc.name)
	}
}

// copyDir recursively copies src into dst. Used to sandbox each scenario
// into a fresh demo-project so test runs don't accumulate scratch files in
// the real repo.
func copyDir(src, dst string) error {
	return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(src, p)
		if err != nil {
			return err
		}
		target := filepath.Join(dst, rel)
		if info.IsDir() {
			return os.MkdirAll(target, 0o755)
		}
		raw, err := os.ReadFile(p)
		if err != nil {
			return err
		}
		if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
			return err
		}
		return os.WriteFile(target, raw, 0o644)
	})
}

// snapshotScratch records the set of pre-existing scratch files under dir.
// Tests capture this BEFORE running, then call checkUntouched AFTER to
// detect only files created during the test — not pollution left behind
// by earlier live-server smokes.
func snapshotScratch(dir string) map[string]bool {
	out := map[string]bool{}
	_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
		if err != nil || info == nil || info.IsDir() {
			return nil
		}
		name := info.Name()
		if strings.HasPrefix(name, "scratch-") || strings.HasPrefix(name, "scratch_") {
			out[p] = true
		}
		return nil
	})
	return out
}

// checkUntouched reports any scratch files under `dir` that weren't in the
// pre-existing snapshot. Returns true if nothing new leaked.
func checkUntouched(t *testing.T, dir string, pre map[string]bool) bool {
	t.Helper()
	leak := false
	_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
		if err != nil || info == nil || info.IsDir() {
			return nil
		}
		name := info.Name()
		if !strings.HasPrefix(name, "scratch-") && !strings.HasPrefix(name, "scratch_") {
			return nil
		}
		if pre[p] {
			return nil // pre-existing pollution from earlier live runs; not this test's fault
		}
		t.Errorf("scratch file leaked into production tree: %s", p)
		leak = true
		return nil
	})
	return !leak
}

func TestScenario1_FeatureImplementation(t *testing.T) {
	runScenario(t, scenario{
		name:    "feature",
		orgYAML: "hierarchical-v1.yaml",
		prompt:  "add a configurable step size to the Counter so + and - buttons increment by step instead of 1",
		wantEvents: []event.Kind{
			event.KindAgentSpawned,
			event.KindTaskCreated,
			event.KindTaskCompleted,
			"agent.handle_one.started",
			"agent.tool.called",
			"agent.artifact_written",
			event.KindRunEnded,
			"evaluation.recorded",
		},
		wantNoEvent: []event.Kind{
			"agent.spawn.failed", "run.stalled", "run.spawn_failed",
			"agent.tool.failed",
		},
		wantArtPath: ".td/demo-project/",
	})
}

func TestScenario2_DebugInvestigation(t *testing.T) {
	runScenario(t, scenario{
		name:    "debug",
		orgYAML: "instruction-v1.yaml",
		prompt:  "the Counter component is broken — reset decrements instead of resetting; investigate",
		wantEvents: []event.Kind{
			event.KindAgentSpawned,
			"agent.query_registry",
			event.KindTaskCompleted,
			"agent.tool.called",
			"agent.artifact_written",
			"evaluation.recorded",
		},
		wantNoEvent: []event.Kind{
			"agent.spawn.failed", "run.stalled", "agent.tool.failed",
		},
		wantArtPath: "report.md",
	})
}

func TestScenario3_CrossZoneRefactor(t *testing.T) {
	runScenario(t, scenario{
		name:    "refactor",
		orgYAML: "hierarchical-mixed-v1.yaml",
		prompt:  "rename the Counter concept to Tally across the demo project — shared types, frontend Counter.tsx, and backend handlers/counter.go",
		wantEvents: []event.Kind{
			event.KindAgentSpawned,
			event.KindTaskCompleted,
			"agent.handle_one.started",
			"agent.artifact_written",
		},
		wantNoEvent: []event.Kind{
			"agent.spawn.failed", "run.stalled",
			"policy.violation", // zone enforcement should not trip
		},
		wantArtPath: ".td/demo-project/",
	})
}
