package main

import (
	"context"
	"encoding/json"
	"strings"
	"testing"
	"time"

	"github.com/denn-gubsky/loomcycle/internal/config"
	"github.com/denn-gubsky/loomcycle/internal/loop"
	"github.com/denn-gubsky/loomcycle/internal/providers/codejs"
	"github.com/denn-gubsky/loomcycle/internal/tools"
)

// This file EXECUTES dev/exec's shipped code-js body through the REAL agent loop +
// the real code-js provider, against a fake mcp__sandbox__* toolset that records
// every dispatch. Grepping the source would only prove the text is present; running
// it proves the command envelope is translated into the right open→(write)→exec→
// (read)→close sequence, that a failing command stops the chain, that a reused
// session is never closed, and that the JS actually COMPILES + RUNS. Hermetic:
// the code-js provider is in-process and the tools are local fakes.

type fakeSandbox struct {
	calls      []recordedCall
	openN      int
	openNet    []string          // network arg of each sandbox_open
	openExpose []string          // expose arg of each sandbox_open
	execErr    map[string]bool   // commands whose exec should return IsError
	execOut    map[string]string // commands whose exec returns a scripted output
}

func newFakeSandbox() *fakeSandbox {
	return &fakeSandbox{execErr: map[string]bool{}, execOut: map[string]string{}}
}

func (s *fakeSandbox) execs() []string {
	var out []string
	for _, c := range s.calls {
		if c.Tool == "mcp__sandbox__sandbox_exec" {
			cmd, _ := c.Input["command"].(string)
			out = append(out, cmd)
		}
	}
	return out
}

func (s *fakeSandbox) hasTool(name string) bool {
	for _, c := range s.calls {
		if c.Tool == name {
			return true
		}
	}
	return false
}

// sbxTool is one fake mcp__sandbox__sandbox_* tool, bound by its exact canonical
// name so code-js reaches it as mcp__sandbox__sandbox_open({...}) etc.
type sbxTool struct {
	s    *fakeSandbox
	name string
}

func (t *sbxTool) Name() string                 { return t.name }
func (t *sbxTool) Description() string          { return "sandbox test double" }
func (t *sbxTool) InputSchema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }

func (t *sbxTool) Execute(_ context.Context, raw json.RawMessage) (tools.Result, error) {
	in := map[string]any{}
	_ = json.Unmarshal(raw, &in)
	t.s.calls = append(t.s.calls, recordedCall{Tool: t.name, Input: in})
	switch t.name {
	case "mcp__sandbox__sandbox_open":
		t.s.openN++
		net, _ := in["network"].(string)
		t.s.openNet = append(t.s.openNet, net)
		exp, _ := in["expose"].(string)
		t.s.openExpose = append(t.s.openExpose, exp)
		out := map[string]any{"session_id": "s_test", "workspace_path": "/work", "network": net}
		if exp != "" {
			// Mirror the real sidecar: an accepted expose attaches an alias network and
			// returns the reachable host.
			out["exposed"] = true
			out["exposed_host"] = exp
		}
		return okResult(out)
	case "mcp__sandbox__sandbox_exec":
		cmd, _ := in["command"].(string)
		if t.s.execErr[cmd] {
			// Mirror the real tool: a non-zero exit is IsError with the output.
			return tools.Result{IsError: true, Text: "boom\n[exit: 1]"}, nil
		}
		if out, ok := t.s.execOut[cmd]; ok {
			return tools.Result{Text: out}, nil // scripted output (e.g. a long one)
		}
		return tools.Result{Text: "ok: " + cmd}, nil // raw command output (not JSON)
	case "mcp__sandbox__sandbox_write":
		return tools.Result{Text: "wrote"}, nil
	case "mcp__sandbox__sandbox_read":
		return tools.Result{Text: "file-contents"}, nil
	case "mcp__sandbox__sandbox_close":
		return tools.Result{Text: "closed " + firstString(in["session_id"])}, nil
	}
	return tools.Result{IsError: true, Text: "unexpected tool " + t.name}, nil
}

func firstString(v any) string { s, _ := v.(string); return s }

func sandboxToolset(s *fakeSandbox) []tools.Tool {
	names := []string{
		"mcp__sandbox__sandbox_open", "mcp__sandbox__sandbox_exec",
		"mcp__sandbox__sandbox_write", "mcp__sandbox__sandbox_read",
		"mcp__sandbox__sandbox_close",
	}
	set := make([]tools.Tool, 0, len(names))
	for _, n := range names {
		set = append(set, &sbxTool{s: s, name: n})
	}
	return set
}

// runDevExec drives the SHIPPED dev/exec code-js body through the real loop against
// the fake sandbox toolset with the given JSON envelope as the prompt.
func runDevExec(t *testing.T, s *fakeSandbox, envelope string) loop.RunResult {
	t.Helper()
	t.Setenv("LOOMCYCLE_SKILLS_ROOT", "")
	cfg, err := config.LoadLayers(layersFor(t, "base", "sandbox", "dev-exec")...)
	if err != nil {
		t.Fatalf("load base+sandbox+dev-exec: %v", err)
	}
	agent, ok := cfg.Agents["dev/exec"]
	if !ok {
		t.Fatalf("dev/exec not registered (agents: %v)", agentNames(cfg))
	}
	set := sandboxToolset(s)
	prov := codejs.New(codejs.Config{CodeRoot: t.TempDir(), RunTimeout: 30 * time.Second})
	res, err := loop.Run(context.Background(), loop.RunOptions{
		Provider:   prov,
		Model:      "code-js",
		AgentName:  "dev/exec",
		CodeBody:   agent.Code,
		Tools:      set,
		Dispatcher: tools.NewDispatcher(set),
		Segments: []loop.PromptSegment{{
			Role:    "user",
			Content: []loop.PromptContentBlock{{Type: "trusted-text", Text: envelope}},
		}},
	})
	if err != nil {
		t.Fatalf("loop.Run: %v\ncalls so far: %v", err, s.calls)
	}
	return res
}

// TestDevExec_RunsEnvelopeOpenWriteExecReadClose is the happy path: one session,
// files written, commands run in order (network defaults to egress), artifacts
// read, session closed.
func TestDevExec_RunsEnvelopeOpenWriteExecReadClose(t *testing.T) {
	s := newFakeSandbox()
	env := `{"files":[{"path":"main.go","content":"package main"}],` +
		`"commands":["echo hi","go build ./..."],"read":["bin/app"]}`

	res := runDevExec(t, s, env)

	if s.openN != 1 {
		t.Fatalf("want exactly 1 sandbox_open, got %d", s.openN)
	}
	if len(s.openNet) == 0 || s.openNet[0] != "egress" {
		t.Errorf("open network = %v, want egress (the dev default)", s.openNet)
	}
	if !s.hasTool("mcp__sandbox__sandbox_write") {
		t.Errorf("files were not written; calls %v", s.execs())
	}
	if !s.hasTool("mcp__sandbox__sandbox_read") {
		t.Errorf("read artifacts were not fetched")
	}
	if !s.hasTool("mcp__sandbox__sandbox_close") {
		t.Errorf("a freshly-opened session must be closed when keep_open is unset")
	}
	// The two user commands ran, in order (a best-effort `gh auth setup-git` exec
	// may precede them — assert order among the user commands, not exact count).
	execs := s.execs()
	if !containsInOrder(execs, "echo hi", "go build ./...") {
		t.Errorf("user commands not run in order; execs=%v", execs)
	}
	if res.StopReason != "end_turn" {
		t.Errorf("stop reason = %q, want end_turn", res.StopReason)
	}
	// final_text reports the counts AND carries each command's OUTPUT (v1.42.1) — a
	// blocking caller only ever sees final_text, so the outputs must be in it.
	if !strings.Contains(res.FinalText, "2 ok, 0 failed") {
		t.Errorf("final_text = %q, want it to report 2 ok / 0 failed", res.FinalText)
	}
	for _, want := range []string{"ok: echo hi", "ok: go build ./...", "file-contents"} {
		if !strings.Contains(res.FinalText, want) {
			t.Errorf("final_text must include the command/artifact output %q; got:\n%s", want, res.FinalText)
		}
	}
}

// TestDevExec_StopsAtFirstFailingCommand: a non-zero exit halts the chain (the
// later command never runs), the session still closes, and ok is false.
func TestDevExec_StopsAtFirstFailingCommand(t *testing.T) {
	s := newFakeSandbox()
	s.execErr["boom"] = true
	env := `{"commands":["ok-1","boom","never"]}`

	res := runDevExec(t, s, env)

	execs := s.execs()
	if containsStr(execs, "never") {
		t.Errorf("a command ran after a failure — the chain must stop at the first failure; execs=%v", execs)
	}
	if !containsStr(execs, "boom") {
		t.Errorf("the failing command was never attempted; execs=%v", execs)
	}
	if !s.hasTool("mcp__sandbox__sandbox_close") {
		t.Errorf("the session must still close after a failing command")
	}
	if !strings.Contains(res.FinalText, "1 failed") {
		t.Errorf("final_text = %q, want it to report the failure count", res.FinalText)
	}
	// The failing command's block must carry its OUTPUT (+ a FAILED tag) so the
	// caller can diagnose — "[exit: 1]" appears only in the command output.
	if !strings.Contains(res.FinalText, "[exit: 1]") || !strings.Contains(res.FinalText, "[FAILED]") {
		t.Errorf("final_text must include the failed command's output + a FAILED tag; got:\n%s", res.FinalText)
	}
}

// TestDevExec_ReusedSessionIsNotOpenedOrClosed: passing session_id means the caller
// owns the container's lifecycle — dev/exec neither opens a new one nor closes it.
func TestDevExec_ReusedSessionIsNotOpenedOrClosed(t *testing.T) {
	s := newFakeSandbox()
	env := `{"session_id":"s_external","commands":["echo hi"]}`

	runDevExec(t, s, env)

	if s.openN != 0 {
		t.Errorf("a reused session must NOT open a new one (opened %d)", s.openN)
	}
	if s.hasTool("mcp__sandbox__sandbox_close") {
		t.Errorf("a reused (caller-owned) session must NOT be closed unless close=true")
	}
	// The command ran against the passed-in session id.
	for _, c := range s.calls {
		if c.Tool == "mcp__sandbox__sandbox_exec" {
			if c.Input["session_id"] != "s_external" {
				t.Errorf("exec ran against %v, want the reused s_external", c.Input["session_id"])
			}
		}
	}
}

// TestDevExec_EmptyEnvelopeDoesNothing: no commands/files/read/browser → a usage
// message and NO container is opened (nothing to run).
func TestDevExec_EmptyEnvelopeDoesNothing(t *testing.T) {
	s := newFakeSandbox()

	res := runDevExec(t, s, `{}`)

	if s.openN != 0 || len(s.calls) != 0 {
		t.Errorf("an empty envelope must not touch the sandbox; calls=%v", s.calls)
	}
	if !strings.Contains(res.FinalText, "nothing to do") {
		t.Errorf("final_text = %q, want a usage/nothing-to-do message", res.FinalText)
	}
}

// TestDevExec_TailCapsLongOutput: a per-command output longer than output_cap is
// truncated to its TAIL (a failure's message is at the end), with a marker.
func TestDevExec_TailCapsLongOutput(t *testing.T) {
	s := newFakeSandbox()
	s.execOut["big"] = "UNIQUEHEAD" + strings.Repeat("x", 200) + "UNIQUETAIL"
	res := runDevExec(t, s, `{"output_cap":20,"commands":["big"]}`)

	if !strings.Contains(res.FinalText, "truncated") {
		t.Errorf("expected a truncation marker; got:\n%s", res.FinalText)
	}
	if !strings.Contains(res.FinalText, "UNIQUETAIL") {
		t.Errorf("tail-cap must keep the output TAIL; got:\n%s", res.FinalText)
	}
	if strings.Contains(res.FinalText, "UNIQUEHEAD") {
		t.Errorf("tail-cap must drop the output HEAD; got:\n%s", res.FinalText)
	}
}

// TestDevExec_ExposePassesThroughToOpen: an `expose` field in the envelope reaches
// sandbox_open, and the reachable host the sidecar returns is surfaced in final_text
// so a blocking caller can point the browser tools at the serve-and-test session.
func TestDevExec_ExposePassesThroughToOpen(t *testing.T) {
	s := newFakeSandbox()
	res := runDevExec(t, s, `{"expose":"devsrv","commands":["echo hi"]}`)

	if len(s.openExpose) == 0 || s.openExpose[0] != "devsrv" {
		t.Fatalf("expose must reach sandbox_open; openExpose=%v", s.openExpose)
	}
	if !strings.Contains(res.FinalText, "devsrv") {
		t.Errorf("final_text must surface the reachable host; got:\n%s", res.FinalText)
	}
}

func containsStr(hay []string, s string) bool {
	for _, h := range hay {
		if h == s {
			return true
		}
	}
	return false
}

// containsInOrder reports whether want appears as an ordered (not necessarily
// contiguous) subsequence of got.
func containsInOrder(got []string, want ...string) bool {
	i := 0
	for _, g := range got {
		if i < len(want) && g == want[i] {
			i++
		}
	}
	return i == len(want)
}
