package agent

import (
	"encoding/json"
	"fmt"
	"log/slog"
	"strings"
	"testing"
)

func TestNewReturnsOpencodeBackend(t *testing.T) {
	t.Parallel()
	b, err := New("opencode", Config{ExecutablePath: "/nonexistent/opencode"})
	if err != nil {
		t.Fatalf("New(opencode) error: %v", err)
	}
	if _, ok := b.(*opencodeBackend); !ok {
		t.Fatalf("expected *opencodeBackend, got %T", b)
	}
}

// ── Text event tests ──

func TestOpencodeHandleTextEvent(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{}
	ch := make(chan Message, 10)
	var output strings.Builder

	event := opencodeEvent{
		Type:      "text",
		SessionID: "ses_abc",
		Part: opencodeEventPart{
			Type: "text",
			Text: "Hello from opencode",
		},
	}

	b.handleTextEvent(event, ch, &output)

	if output.String() != "Hello from opencode" {
		t.Errorf("output: got %q, want %q", output.String(), "Hello from opencode")
	}
	msg := <-ch
	if msg.Type != MessageText {
		t.Errorf("type: got %v, want MessageText", msg.Type)
	}
	if msg.Content != "Hello from opencode" {
		t.Errorf("content: got %q, want %q", msg.Content, "Hello from opencode")
	}
}

func TestOpencodeHandleTextEventEmpty(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{}
	ch := make(chan Message, 10)
	var output strings.Builder

	event := opencodeEvent{
		Type: "text",
		Part: opencodeEventPart{Type: "text", Text: ""},
	}

	b.handleTextEvent(event, ch, &output)

	if output.String() != "" {
		t.Errorf("expected empty output, got %q", output.String())
	}
	if len(ch) != 0 {
		t.Errorf("expected no messages, got %d", len(ch))
	}
}

// ── Tool use event tests (real opencode schema) ──

func TestOpencodeHandleToolUseEventCompleted(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{}
	ch := make(chan Message, 10)

	// Real opencode tool_use event: single event with state containing both
	// call parameters and result.
	event := opencodeEvent{
		Type: "tool_use",
		Part: opencodeEventPart{
			Tool:   "bash",
			CallID: "call_BHA1",
			State: &opencodeToolState{
				Status: "completed",
				Input:  json.RawMessage(`{"command":"pwd","description":"Prints current working directory path"}`),
				Output: "/tmp/multica\n",
			},
		},
	}

	b.handleToolUseEvent(event, ch)

	// Should emit both a tool-use and a tool-result message.
	if len(ch) != 2 {
		t.Fatalf("expected 2 messages, got %d", len(ch))
	}

	// First: tool-use
	msg := <-ch
	if msg.Type != MessageToolUse {
		t.Errorf("type: got %v, want MessageToolUse", msg.Type)
	}
	if msg.Tool != "bash" {
		t.Errorf("tool: got %q, want %q", msg.Tool, "bash")
	}
	if msg.CallID != "call_BHA1" {
		t.Errorf("callID: got %q, want %q", msg.CallID, "call_BHA1")
	}
	if cmd, ok := msg.Input["command"].(string); !ok || cmd != "pwd" {
		t.Errorf("input.command: got %v", msg.Input["command"])
	}

	// Second: tool-result
	msg = <-ch
	if msg.Type != MessageToolResult {
		t.Errorf("type: got %v, want MessageToolResult", msg.Type)
	}
	if msg.CallID != "call_BHA1" {
		t.Errorf("callID: got %q, want %q", msg.CallID, "call_BHA1")
	}
	if msg.Output != "/tmp/multica\n" {
		t.Errorf("output: got %q", msg.Output)
	}
}

func TestOpencodeHandleToolUseEventPending(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{}
	ch := make(chan Message, 10)

	// Tool use with pending status — only emit tool-use, no result.
	event := opencodeEvent{
		Type: "tool_use",
		Part: opencodeEventPart{
			Tool:   "read",
			CallID: "call_ABC",
			State: &opencodeToolState{
				Status: "pending",
				Input:  json.RawMessage(`{"filePath":"/tmp/test.go"}`),
			},
		},
	}

	b.handleToolUseEvent(event, ch)

	if len(ch) != 1 {
		t.Fatalf("expected 1 message for pending tool, got %d", len(ch))
	}
	msg := <-ch
	if msg.Type != MessageToolUse {
		t.Errorf("type: got %v, want MessageToolUse", msg.Type)
	}
	if msg.Tool != "read" {
		t.Errorf("tool: got %q, want %q", msg.Tool, "read")
	}
}

func TestOpencodeHandleToolUseEventStructuredOutput(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{}
	ch := make(chan Message, 10)

	// Tool with structured (non-string) output.
	event := opencodeEvent{
		Type: "tool_use",
		Part: opencodeEventPart{
			Tool:   "glob",
			CallID: "call_XYZ",
			State: &opencodeToolState{
				Status: "completed",
				Input:  json.RawMessage(`{"pattern":"*.go"}`),
				Output: map[string]any{"files": []any{"main.go", "main_test.go"}},
			},
		},
	}

	b.handleToolUseEvent(event, ch)

	// tool-use + tool-result
	if len(ch) != 2 {
		t.Fatalf("expected 2 messages, got %d", len(ch))
	}
	<-ch // skip tool-use
	msg := <-ch
	if msg.Type != MessageToolResult {
		t.Errorf("type: got %v, want MessageToolResult", msg.Type)
	}
	if !strings.Contains(msg.Output, "main.go") {
		t.Errorf("output should contain 'main.go', got %q", msg.Output)
	}
}

func TestOpencodeHandleToolUseEventNilState(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{}
	ch := make(chan Message, 10)

	// Tool use with no state at all — should emit tool-use with no crash.
	event := opencodeEvent{
		Type: "tool_use",
		Part: opencodeEventPart{
			Tool:   "write",
			CallID: "call_NUL",
		},
	}

	b.handleToolUseEvent(event, ch)

	if len(ch) != 1 {
		t.Fatalf("expected 1 message, got %d", len(ch))
	}
	msg := <-ch
	if msg.Type != MessageToolUse {
		t.Errorf("type: got %v, want MessageToolUse", msg.Type)
	}
}

// ── Error event tests ──

func TestOpencodeHandleErrorEvent(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
	ch := make(chan Message, 10)
	status := "completed"
	errMsg := ""

	event := opencodeEvent{
		Type:      "error",
		SessionID: "ses_abc",
		Error: &opencodeError{
			Name: "UnknownError",
			Data: &opencodeErrData{
				Message: "Model not found: definitely/not-a-model.",
			},
		},
	}

	b.handleErrorEvent(event, ch, &status, &errMsg)

	if status != "failed" {
		t.Errorf("status: got %q, want %q", status, "failed")
	}
	if errMsg != "Model not found: definitely/not-a-model." {
		t.Errorf("error: got %q", errMsg)
	}
	msg := <-ch
	if msg.Type != MessageError {
		t.Errorf("type: got %v, want MessageError", msg.Type)
	}
	if msg.Content != "Model not found: definitely/not-a-model." {
		t.Errorf("content: got %q", msg.Content)
	}
}

func TestOpencodeHandleErrorEventNameOnly(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
	ch := make(chan Message, 10)
	status := "completed"
	errMsg := ""

	// Error with name but no data.message — should fall back to name.
	event := opencodeEvent{
		Type: "error",
		Error: &opencodeError{
			Name: "RateLimitError",
		},
	}

	b.handleErrorEvent(event, ch, &status, &errMsg)

	if errMsg != "RateLimitError" {
		t.Errorf("error: got %q, want %q", errMsg, "RateLimitError")
	}
}

func TestOpencodeHandleErrorEventNilError(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
	ch := make(chan Message, 10)
	status := "completed"
	errMsg := ""

	event := opencodeEvent{Type: "error"}

	b.handleErrorEvent(event, ch, &status, &errMsg)

	if errMsg != "unknown opencode error" {
		t.Errorf("error: got %q, want %q", errMsg, "unknown opencode error")
	}
}

// ── JSON parsing tests with real fixtures ──

func TestOpencodeEventParsingTextFixture(t *testing.T) {
	t.Parallel()

	line := `{"type":"text","timestamp":1775116675833,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","type":"text","text":"pong","time":{"start":1775116675833,"end":1775116675833}}}`

	var event opencodeEvent
	if err := json.Unmarshal([]byte(line), &event); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if event.Type != "text" {
		t.Errorf("type: got %q, want %q", event.Type, "text")
	}
	if event.SessionID != "ses_abc" {
		t.Errorf("sessionID: got %q, want %q", event.SessionID, "ses_abc")
	}
	if event.Part.Text != "pong" {
		t.Errorf("part.text: got %q, want %q", event.Part.Text, "pong")
	}
}

func TestOpencodeEventParsingToolUseFixture(t *testing.T) {
	t.Parallel()

	// Real `tool_use` JSON from live `opencode run --format json` output.
	line := `{"type":"tool_use","timestamp":1775117187163,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","type":"tool","tool":"bash","callID":"call_BHA1","state":{"status":"completed","input":{"command":"pwd","description":"Prints current working directory path"},"output":"/tmp/multica\n","metadata":{"exit":0},"time":{"start":1775117187092,"end":1775117187162}}}}`

	var event opencodeEvent
	if err := json.Unmarshal([]byte(line), &event); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if event.Type != "tool_use" {
		t.Errorf("type: got %q, want %q", event.Type, "tool_use")
	}
	if event.Part.Tool != "bash" {
		t.Errorf("part.tool: got %q, want %q", event.Part.Tool, "bash")
	}
	if event.Part.CallID != "call_BHA1" {
		t.Errorf("part.callID: got %q, want %q", event.Part.CallID, "call_BHA1")
	}
	if event.Part.State == nil {
		t.Fatal("part.state is nil")
	}
	if event.Part.State.Status != "completed" {
		t.Errorf("state.status: got %q, want %q", event.Part.State.Status, "completed")
	}

	// Parse state.input
	var input map[string]any
	if err := json.Unmarshal(event.Part.State.Input, &input); err != nil {
		t.Fatalf("unmarshal state.input: %v", err)
	}
	if input["command"] != "pwd" {
		t.Errorf("state.input.command: got %v, want %q", input["command"], "pwd")
	}

	// state.output should be a string
	if output, ok := event.Part.State.Output.(string); !ok || output != "/tmp/multica\n" {
		t.Errorf("state.output: got %v (%T)", event.Part.State.Output, event.Part.State.Output)
	}
}

func TestOpencodeEventParsingErrorFixture(t *testing.T) {
	t.Parallel()

	line := `{"type":"error","timestamp":1775117233612,"sessionID":"ses_abc","error":{"name":"UnknownError","data":{"message":"Model not found: definitely/not-a-model."}}}`

	var event opencodeEvent
	if err := json.Unmarshal([]byte(line), &event); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if event.Type != "error" {
		t.Errorf("type: got %q, want %q", event.Type, "error")
	}
	if event.Error == nil {
		t.Fatal("error field is nil")
	}
	if event.Error.Name != "UnknownError" {
		t.Errorf("error.name: got %q", event.Error.Name)
	}
	if got := event.Error.Message(); got != "Model not found: definitely/not-a-model." {
		t.Errorf("error.Message(): got %q", got)
	}
}

func TestOpencodeEventParsingStepStartFixture(t *testing.T) {
	t.Parallel()

	line := `{"type":"step_start","timestamp":1775116675819,"sessionID":"ses_abc","part":{"id":"prt_123","messageID":"msg_456","sessionID":"ses_abc","snapshot":"abc123","type":"step-start"}}`

	var event opencodeEvent
	if err := json.Unmarshal([]byte(line), &event); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if event.Type != "step_start" {
		t.Errorf("type: got %q, want %q", event.Type, "step_start")
	}
	if event.SessionID != "ses_abc" {
		t.Errorf("sessionID: got %q", event.SessionID)
	}
}

func TestOpencodeStepFinishParsing(t *testing.T) {
	t.Parallel()

	line := `{"type":"step_finish","timestamp":1775116676180,"sessionID":"ses_abc","part":{"id":"prt_789","reason":"stop","snapshot":"abc123","messageID":"msg_456","sessionID":"ses_abc","type":"step-finish","tokens":{"total":14674,"input":14585,"output":89,"reasoning":82,"cache":{"write":0,"read":0}},"cost":0}}`

	var event opencodeEvent
	if err := json.Unmarshal([]byte(line), &event); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if event.Type != "step_finish" {
		t.Errorf("type: got %q, want %q", event.Type, "step_finish")
	}
	if event.SessionID != "ses_abc" {
		t.Errorf("sessionID: got %q", event.SessionID)
	}
}

// ── extractToolOutput tests ──

func TestExtractToolOutputString(t *testing.T) {
	t.Parallel()
	if got := extractToolOutput("hello\n"); got != "hello\n" {
		t.Errorf("got %q, want %q", got, "hello\n")
	}
}

func TestExtractToolOutputNil(t *testing.T) {
	t.Parallel()
	if got := extractToolOutput(nil); got != "" {
		t.Errorf("got %q, want empty", got)
	}
}

func TestExtractToolOutputStructured(t *testing.T) {
	t.Parallel()
	obj := map[string]any{"key": "value"}
	got := extractToolOutput(obj)
	if !strings.Contains(got, `"key"`) || !strings.Contains(got, `"value"`) {
		t.Errorf("got %q, expected JSON containing key/value", got)
	}
}

// ── opencodeError.Message() tests ──

func TestOpencodeErrorMessage(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name string
		err  *opencodeError
		want string
	}{
		{
			name: "data message",
			err:  &opencodeError{Name: "Err", Data: &opencodeErrData{Message: "details"}},
			want: "details",
		},
		{
			name: "name only",
			err:  &opencodeError{Name: "RateLimitError"},
			want: "RateLimitError",
		},
		{
			name: "empty",
			err:  &opencodeError{},
			want: "",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := tt.err.Message(); got != tt.want {
				t.Errorf("Message() = %q, want %q", got, tt.want)
			}
		})
	}
}

// ── Integration-level tests: processEvents ──
//
// These feed multiple JSON lines through processEvents and verify the
// accumulated result (status, output, sessionID, error) and emitted messages.

func TestOpencodeProcessEventsHappyPath(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
	ch := make(chan Message, 256)

	// Simulate a successful run: step_start → text → tool_use → text → step_finish
	lines := strings.Join([]string{
		`{"type":"step_start","timestamp":1000,"sessionID":"ses_happy","part":{"type":"step-start"}}`,
		`{"type":"text","timestamp":1001,"sessionID":"ses_happy","part":{"type":"text","text":"Analyzing the issue..."}}`,
		`{"type":"tool_use","timestamp":1002,"sessionID":"ses_happy","part":{"tool":"bash","callID":"call_1","state":{"status":"completed","input":{"command":"ls"},"output":"file1.go\nfile2.go\n"}}}`,
		`{"type":"text","timestamp":1003,"sessionID":"ses_happy","part":{"type":"text","text":" Done."}}`,
		`{"type":"step_finish","timestamp":1004,"sessionID":"ses_happy","part":{"type":"step-finish"}}`,
	}, "\n")

	result := b.processEvents(strings.NewReader(lines), ch)

	// Verify result.
	if result.status != "completed" {
		t.Errorf("status: got %q, want %q", result.status, "completed")
	}
	if result.sessionID != "ses_happy" {
		t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_happy")
	}
	if result.output != "Analyzing the issue... Done." {
		t.Errorf("output: got %q, want %q", result.output, "Analyzing the issue... Done.")
	}
	if result.errMsg != "" {
		t.Errorf("errMsg: got %q, want empty", result.errMsg)
	}

	// Drain and verify messages.
	close(ch)
	var msgs []Message
	for m := range ch {
		msgs = append(msgs, m)
	}

	// Expected: status(running), text, tool-use, tool-result, text, = 5 messages
	if len(msgs) != 5 {
		t.Fatalf("expected 5 messages, got %d: %+v", len(msgs), msgs)
	}
	if msgs[0].Type != MessageStatus || msgs[0].Status != "running" {
		t.Errorf("msg[0]: got %+v, want status=running", msgs[0])
	}
	if msgs[1].Type != MessageText || msgs[1].Content != "Analyzing the issue..." {
		t.Errorf("msg[1]: got %+v", msgs[1])
	}
	if msgs[2].Type != MessageToolUse || msgs[2].Tool != "bash" {
		t.Errorf("msg[2]: got %+v, want tool-use(bash)", msgs[2])
	}
	if msgs[3].Type != MessageToolResult || msgs[3].Output != "file1.go\nfile2.go\n" {
		t.Errorf("msg[3]: got %+v, want tool-result", msgs[3])
	}
	if msgs[4].Type != MessageText || msgs[4].Content != " Done." {
		t.Errorf("msg[4]: got %+v", msgs[4])
	}
}

func TestOpencodeProcessEventsErrorCausesFailedStatus(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
	ch := make(chan Message, 256)

	// Simulate: step_start → error (model not found) → step_finish.
	// OpenCode exits RC=0 on error events, so the error event is the only
	// signal that something went wrong.
	lines := strings.Join([]string{
		`{"type":"step_start","timestamp":1000,"sessionID":"ses_err","part":{"type":"step-start"}}`,
		`{"type":"error","timestamp":1001,"sessionID":"ses_err","error":{"name":"UnknownError","data":{"message":"Model not found: bad/model"}}}`,
		`{"type":"step_finish","timestamp":1002,"sessionID":"ses_err","part":{"type":"step-finish"}}`,
	}, "\n")

	result := b.processEvents(strings.NewReader(lines), ch)

	if result.status != "failed" {
		t.Errorf("status: got %q, want %q", result.status, "failed")
	}
	if result.errMsg != "Model not found: bad/model" {
		t.Errorf("errMsg: got %q", result.errMsg)
	}
	if result.sessionID != "ses_err" {
		t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_err")
	}

	close(ch)
	var errorMsgs int
	for m := range ch {
		if m.Type == MessageError {
			errorMsgs++
		}
	}
	if errorMsgs != 1 {
		t.Errorf("expected 1 error message, got %d", errorMsgs)
	}
}

func TestOpencodeProcessEventsSessionIDExtracted(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
	ch := make(chan Message, 256)

	// Session ID should be captured from the last event that has one.
	lines := strings.Join([]string{
		`{"type":"step_start","timestamp":1000,"sessionID":"ses_first","part":{"type":"step-start"}}`,
		`{"type":"text","timestamp":1001,"sessionID":"ses_updated","part":{"type":"text","text":"hi"}}`,
	}, "\n")

	result := b.processEvents(strings.NewReader(lines), ch)

	if result.sessionID != "ses_updated" {
		t.Errorf("sessionID: got %q, want %q (should use last seen)", result.sessionID, "ses_updated")
	}

	close(ch)
}

func TestOpencodeProcessEventsScannerError(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
	ch := make(chan Message, 256)

	// Use an ioErrReader that returns valid data then an I/O error, which
	// triggers scanner.Err() and should set status to "failed".
	result := b.processEvents(&ioErrReader{
		data: `{"type":"text","sessionID":"ses_scan","part":{"text":"before error"}}` + "\n",
	}, ch)

	if result.status != "failed" {
		t.Errorf("status: got %q, want %q", result.status, "failed")
	}
	if !strings.Contains(result.errMsg, "stdout read error") {
		t.Errorf("errMsg: got %q, want it to contain 'stdout read error'", result.errMsg)
	}
	// The text event before the error should still be captured.
	if result.output != "before error" {
		t.Errorf("output: got %q, want %q", result.output, "before error")
	}

	close(ch)
}

// ioErrReader delivers data on the first Read, then returns an error on the second.
type ioErrReader struct {
	data string
	read bool
}

func (r *ioErrReader) Read(p []byte) (int, error) {
	if !r.read {
		r.read = true
		n := copy(p, r.data)
		return n, nil
	}
	return 0, fmt.Errorf("simulated I/O error")
}

func TestOpencodeProcessEventsEmptyLines(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
	ch := make(chan Message, 256)

	// Empty lines and invalid JSON should be skipped without error.
	lines := strings.Join([]string{
		"",
		"   ",
		"not json at all",
		`{"type":"text","sessionID":"ses_ok","part":{"text":"valid"}}`,
		"",
	}, "\n")

	result := b.processEvents(strings.NewReader(lines), ch)

	if result.status != "completed" {
		t.Errorf("status: got %q, want %q", result.status, "completed")
	}
	if result.output != "valid" {
		t.Errorf("output: got %q, want %q", result.output, "valid")
	}
	if result.sessionID != "ses_ok" {
		t.Errorf("sessionID: got %q, want %q", result.sessionID, "ses_ok")
	}

	close(ch)
	var msgs []Message
	for m := range ch {
		msgs = append(msgs, m)
	}
	if len(msgs) != 1 || msgs[0].Type != MessageText {
		t.Errorf("expected 1 text message, got %d: %+v", len(msgs), msgs)
	}
}

func TestOpencodeProcessEventsErrorDoesNotRevertToCompleted(t *testing.T) {
	t.Parallel()

	b := &opencodeBackend{cfg: Config{Logger: slog.Default()}}
	ch := make(chan Message, 256)

	// Error event followed by more text — status should remain "failed".
	lines := strings.Join([]string{
		`{"type":"error","sessionID":"ses_x","error":{"name":"RateLimitError"}}`,
		`{"type":"text","sessionID":"ses_x","part":{"text":"recovered?"}}`,
	}, "\n")

	result := b.processEvents(strings.NewReader(lines), ch)

	if result.status != "failed" {
		t.Errorf("status: got %q, want %q (error should stick)", result.status, "failed")
	}
	if result.errMsg != "RateLimitError" {
		t.Errorf("errMsg: got %q, want %q", result.errMsg, "RateLimitError")
	}

	close(ch)
}
