package agent

import (
	"context"
	"encoding/json"
	"path/filepath"
	"strconv"
	"strings"
	"sync/atomic"
	"testing"

	"reasonix/internal/event"
	"reasonix/internal/evidence"
	"reasonix/internal/provider"
	"reasonix/internal/runtimepolicy"
	"reasonix/internal/taskcontract"
	"reasonix/internal/tool"
)

func setTurnConstraints(a *Agent, raw string) {
	c := runtimepolicy.ParseConstraints(runtimepolicy.StripQuotedConstraints(raw))
	a.turn.constraints = c
	a.turn.engine = runtimepolicy.NewEngine(c)
}

func TestRebuildTurnContractEnforcesExplicitFullVerification(t *testing.T) {
	a := New(nil, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
	a.resetTurnEvidence()
	setTurnConstraints(a, "请闭环交付")
	a.task.ledger.Record(evidence.Receipt{
		ToolName: "edit_file", Success: true, Write: true, Mutation: true,
		Args: json.RawMessage(`{"path":"README.md"}`), Paths: []string{"README.md"},
	})
	a.rebuildTurnContract()
	for _, obligation := range a.turn.engine.Snapshot().Unsatisfied() {
		if obligation.Kind == taskcontract.ObligationFullVerify && obligation.Enforcement == taskcontract.EnforcementStrict {
			return
		}
	}
	t.Fatalf("Agent rebuild dropped explicit full verification: %+v", a.turn.engine.Snapshot().Obligations)
}

func TestTaskPolicyUsesStructuredCommandEffects(t *testing.T) {
	var calls int32
	reg := tool.NewRegistry()
	reg.Add(fakeTool{name: "bash", readOnly: false, calls: &calls})
	a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"), Options{}, event.Discard)
	a.turn.constraints = runtimepolicy.Constraints{ForbidMutation: true}
	a.turn.engine = runtimepolicy.NewEngine(a.turn.constraints)

	listing := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "bash", Arguments: `{"command":"git branch -a"}`})
	if listing.blocked || listing.errMsg != "" {
		t.Fatalf("branch listing outcome = %+v, want execution", listing)
	}
	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Fatalf("branch listing Execute calls = %d, want 1", got)
	}

	tests := []struct {
		name       string
		command    string
		wantDomain string
		secret     string
	}{
		{name: "tag creation", command: "git tag v1.2.3", wantDomain: "repository metadata", secret: "v1.2.3"},
		{name: "host clock", command: "date --set tomorrow", wantDomain: "host state", secret: "tomorrow"},
		{name: "audit fix", command: "npm audit fix", wantDomain: "workspace content", secret: "fix"},
		{name: "config edit", command: "git config --edit", wantDomain: "repository metadata", secret: "--edit"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			args, err := json.Marshal(map[string]string{"command": tt.command})
			if err != nil {
				t.Fatal(err)
			}
			got := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "bash", Arguments: string(args)})
			if !got.blocked || !strings.Contains(got.output, "forbid") {
				t.Fatalf("command %q outcome = %+v, want mutation block", tt.command, got)
			}
			if strings.Contains(got.errMsg, tt.secret) && tt.secret != "--edit" {
				t.Fatalf("policy error leaked command operand %q: %q", tt.secret, got.errMsg)
			}
		})
	}
	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Fatalf("blocked writers reached Execute: calls=%d, want 1", got)
	}
}

func TestTaskPolicyEnforcesVerificationAllowlist(t *testing.T) {
	var calls int32
	reg := tool.NewRegistry()
	reg.Add(fakeTool{name: "bash", readOnly: true, calls: &calls})
	a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"), Options{}, event.Discard)
	setTurnConstraints(a, "fix it; only run go test ./internal/parser")
	a.turn.deliveryCriteriaEstablished = true

	for _, command := range []string{"npm test", "go vet ./...", "golangci-lint run", "npm run typecheck"} {
		blocked := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
			Name: "bash", Arguments: `{"command":` + strconv.Quote(command) + `}`,
		})
		if !blocked.blocked || !strings.Contains(blocked.errMsg, "allowlist") {
			t.Fatalf("%s outcome = %+v, want allowlist block", command, blocked)
		}
	}
	if got := atomic.LoadInt32(&calls); got != 0 {
		t.Fatalf("disallowed verification commands executed %d times, want 0", got)
	}
	allowed := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "bash", Arguments: `{"command":"go test ./internal/parser"}`})
	if allowed.blocked || allowed.errMsg != "" {
		t.Fatalf("allowed go test outcome = %+v", allowed)
	}
	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Fatalf("allowed verification command executed %d times, want 1", got)
	}
}

func TestTaskPolicyForbidTestsBlocksEveryVerifier(t *testing.T) {
	var calls int32
	reg := tool.NewRegistry()
	reg.Add(fakeTool{name: "bash", readOnly: true, calls: &calls})
	a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"), Options{}, event.Discard)
	setTurnConstraints(a, "fix it; don't run tests")
	a.turn.deliveryCriteriaEstablished = true

	for _, command := range []string{"go test ./...", "go vet ./...", "golangci-lint run", "npm run typecheck"} {
		got := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
			Name: "bash", Arguments: `{"command":` + strconv.Quote(command) + `}`,
		})
		if !got.blocked || !strings.Contains(got.output, "forbid") {
			t.Fatalf("%s outcome = %+v, want user-constraint block", command, got)
		}
	}
	if got := atomic.LoadInt32(&calls); got != 0 {
		t.Fatalf("forbidden verification commands executed %d times, want 0", got)
	}
}

func TestTaskPolicyBlocksExternalActionCommandVariants(t *testing.T) {
	reg := tool.NewRegistry()
	reg.Add(fakeTool{name: "bash", readOnly: false})
	a := New(&scriptedProvider{name: "p"}, reg, NewSession("sys"), Options{}, event.Discard)
	setTurnConstraints(a, "fix it, but don't push")
	a.turn.deliveryCriteriaEstablished = true
	a.setTodoState([]evidence.TodoItem{{Content: "fix it", Status: "in_progress"}})

	for _, command := range []string{
		"git -C ../repo push origin HEAD",
		"npm --workspace pkg publish",
		"kubectl -n production apply -f deploy.yaml",
	} {
		args, err := json.Marshal(map[string]string{"command": command})
		if err != nil {
			t.Fatal(err)
		}
		got := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "bash", Arguments: string(args)})
		if !got.blocked || !(strings.Contains(got.output, "external") || strings.Contains(got.output, "push") || strings.Contains(got.output, "publish") || strings.Contains(got.output, "deploy")) {
			t.Fatalf("command %q outcome = %+v, want task-policy block", command, got)
		}
	}
}

func TestTaskPolicyBlocksResolvedExternalCapability(t *testing.T) {
	calls := 0
	target := readOnlyBoundaryTarget{name: "mcp__vercel__deploy_project", readOnly: false, calls: &calls}
	proxy := readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
		ProxyAction: "call", TargetName: target.Name(), Target: target, ReadOnly: false, Args: json.RawMessage(`{}`),
	}}
	reg := tool.NewRegistry()
	reg.Add(proxy)
	a := New(nil, reg, NewSession("sys"), Options{}, event.Discard)
	setTurnConstraints(a, "prepare the release, but don't deploy")

	got := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
		ID: "deploy-1", Name: "use_capability", Arguments: `{"action":"call","capability_id":"mcp-tool:vercel/deploy_project"}`,
	})
	if !got.blocked || !strings.Contains(got.output, "deploy") {
		t.Fatalf("resolved deploy outcome = %+v, want deploy block", got)
	}
	if calls != 0 {
		t.Fatalf("resolved deploy Execute calls = %d, want 0", calls)
	}
}

func TestTaskPolicyReportsPostMutationVerificationGapWithoutBlockingTargetedTurn(t *testing.T) {
	reg := tool.NewRegistry()
	reg.Add(fakeTool{name: "bash", readOnly: true})
	writer := evidence.Receipt{ToolName: "write_file", Success: true, Write: true, Mutation: true, Paths: []string{"notes.txt"}}
	check := evidence.Receipt{ToolName: "bash", Success: true, Command: "go test ./..."}
	a := &Agent{
		task: taskRuntime{ledger: readinessLedger(check, writer)},
		svc:  agentServices{tools: reg},
		turn: turnRuntime{engine: runtimepolicy.NewEngine(runtimepolicy.Constraints{})},
	}
	if got := a.finalReadinessCheckFor(); got.reason != "" {
		t.Fatalf("targeted readiness = %+v, want quality gap to remain non-blocking", got)
	}
	a.task.ledger.Record(check)
	if got := a.finalReadinessCheckFor(); got.reason != "" {
		t.Fatalf("readiness after verification = %+v, want ready", got)
	}
}

func TestPolicyEscalatesBeforeFirstSensitiveMutation(t *testing.T) {
	var calls int32
	reg := tool.NewRegistry()
	reg.Add(fakeTool{name: "edit_file", readOnly: false, calls: &calls})
	permission := &stubGate{deny: map[string]bool{}}
	a := New(nil, reg, NewSession("sys"), Options{Gate: permission}, event.Discard)
	setTurnConstraints(a, "fix the typo in README.md")

	got := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
		Name:      "edit_file",
		Arguments: `{"path":"internal/auth/session.go","old_string":"old","new_string":"new"}`,
	})
	if !got.blocked || !strings.Contains(got.errMsg, "acceptance criteria") {
		t.Fatalf("sensitive first mutation outcome = %+v, want pre-execution criteria block", got)
	}
	if got := atomic.LoadInt32(&calls); got != 0 {
		t.Fatalf("sensitive writer executed %d times before escalation, want 0", got)
	}
	if len(permission.checked) != 0 {
		t.Fatalf("permission was requested for a deterministically blocked call: %v", permission.checked)
	}
	a.turn.deliveryCriteriaEstablished = true
	a.setTodoState([]evidence.TodoItem{{Content: "update session handling", Status: "in_progress"}})
	got = a.executeOne(context.Background(), &a.turn, provider.ToolCall{
		Name:      "edit_file",
		Arguments: `{"path":"internal/auth/session.go","old_string":"old","new_string":"new"}`,
	})
	if got.blocked || got.errMsg != "" {
		t.Fatalf("sensitive mutation with host contract = %+v, want execution", got)
	}
	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Fatalf("sensitive writer executed %d times after contract, want 1", got)
	}
	if len(permission.checked) != 1 {
		t.Fatalf("permission checks = %v, want exactly one for executable call", permission.checked)
	}
}

func TestPolicyEscalatesDeepAbsoluteSensitiveMutationBeforeExecution(t *testing.T) {
	var calls int32
	root := t.TempDir()
	reg := tool.NewRegistry()
	reg.Add(fakeTool{name: "edit_file", readOnly: false, calls: &calls})
	permission := &stubGate{deny: map[string]bool{}}
	a := New(nil, reg, NewSession("sys"), Options{Gate: permission, WriteWorkspaceRoot: root}, event.Discard)
	setTurnConstraints(a, "fix this file")
	args, err := json.Marshal(map[string]string{
		"path":       filepath.Join(root, "internal", "provider", "openai", "responses", "client.go"),
		"old_string": "old",
		"new_string": "new",
	})
	if err != nil {
		t.Fatal(err)
	}

	got := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "edit_file", Arguments: string(args)})
	if got.blocked {
		t.Fatalf("ordinary production file must not be pre-classified as schema/auth: %+v", got)
	}
}

func TestPlannedLowRiskMutationKeepsOrdinaryPath(t *testing.T) {
	var calls int32
	reg := tool.NewRegistry()
	reg.Add(fakeTool{name: "edit_file", readOnly: false, calls: &calls})
	permission := &stubGate{deny: map[string]bool{}}
	a := New(nil, reg, NewSession("sys"), Options{Gate: permission}, event.Discard)
	setTurnConstraints(a, "fix the typo in README.md")

	got := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
		Name:      "edit_file",
		Arguments: `{"path":"README.md","old_string":"teh","new_string":"the"}`,
	})
	if got.blocked || got.errMsg != "" {
		t.Fatalf("low-risk mutation outcome = %+v, want ordinary execution", got)
	}
	if got := atomic.LoadInt32(&calls); got != 1 {
		t.Fatalf("low-risk writer calls = %d, want 1", got)
	}
	if len(permission.checked) != 1 {
		t.Fatalf("permission checks = %v, want one ordinary check", permission.checked)
	}
	if a.closedLoopActive() {
		t.Fatal("README typo must not create a closed-loop contract")
	}
}
