package sidecarchan

import (
	"context"
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// TestMailboxChannel_OpenSetsUpAgentOutbox: Open must create the agent's
// outbox directory and return an Instruction that names the exact path
// the model is expected to Write to. The Env block carries the path so
// adapter-side prompt templates can embed it without re-deriving.
func TestMailboxChannel_OpenSetsUpAgentOutbox(t *testing.T) {
	dir := t.TempDir()
	ch := NewMailboxChannel(dir)
	defer ch.CloseAll()

	info, err := ch.Open("agent-1")
	if err != nil {
		t.Fatal(err)
	}
	outboxPath := info.Env["HARNESS_MAILBOX_PATH"]
	if outboxPath == "" {
		t.Fatal("Env[HARNESS_MAILBOX_PATH] not set")
	}
	if !strings.Contains(info.Instruction, outboxPath) {
		t.Errorf("Instruction does not mention path %q: %s", outboxPath, info.Instruction)
	}
	// The parent dir must exist so the model's Write call doesn't fail
	// on missing-directory.
	parent := filepath.Dir(outboxPath)
	if st, err := os.Stat(parent); err != nil {
		t.Errorf("outbox parent dir missing: %v", err)
	} else if !st.IsDir() {
		t.Errorf("outbox parent is not a directory: %s", parent)
	}
}

// TestMailboxChannel_ReceiveReadsFileOnceItAppears: after the model
// writes JSON to the agent's outbox path, the next Receive call must
// return exactly that body.
func TestMailboxChannel_ReceiveReadsFileOnceItAppears(t *testing.T) {
	dir := t.TempDir()
	ch := NewMailboxChannel(dir)
	defer ch.CloseAll()

	info, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}
	outboxPath := info.Env["HARNESS_MAILBOX_PATH"]
	payload := `{"text":"hi from mailbox","tool_calls":[],"tokens":{"prompt":4,"completion":2}}`

	// Simulate the model: write the file after a brief delay so Receive
	// is already polling.
	go func() {
		time.Sleep(20 * time.Millisecond)
		_ = writeAtomic(t, outboxPath, payload)
	}()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	got, err := ch.Receive(ctx, "a")
	if err != nil {
		t.Fatal(err)
	}
	if got != payload {
		t.Errorf("Receive payload mismatch\n got: %q\nwant: %q", got, payload)
	}
	if !json.Valid([]byte(got)) {
		t.Errorf("Receive produced invalid JSON: %q", got)
	}
}

// TestMailboxChannel_ReceiveCancellation: model never writes → Receive
// must return ctx.Err() (not hang past the deadline).
func TestMailboxChannel_ReceiveCancellation(t *testing.T) {
	dir := t.TempDir()
	ch := NewMailboxChannel(dir)
	defer ch.CloseAll()
	_, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()
	start := time.Now()
	_, err = ch.Receive(ctx, "a")
	if err == nil {
		t.Fatal("expected cancellation error")
	}
	if d := time.Since(start); d > 500*time.Millisecond {
		t.Errorf("Receive took %v — must honor ctx.Done() promptly", d)
	}
}

// TestMailboxChannel_ConsumesFileAfterRead: after the harness reads the
// file, it must be removed so subsequent Receive calls see fresh state
// (otherwise the same payload would be returned twice for two
// different turns).
func TestMailboxChannel_ConsumesFileAfterRead(t *testing.T) {
	dir := t.TempDir()
	ch := NewMailboxChannel(dir)
	defer ch.CloseAll()
	info, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}
	outboxPath := info.Env["HARNESS_MAILBOX_PATH"]
	if err := writeAtomic(t, outboxPath, `{"text":"first turn"}`); err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()
	if _, err := ch.Receive(ctx, "a"); err != nil {
		t.Fatal(err)
	}
	// File should be gone now.
	if _, err := os.Stat(outboxPath); !os.IsNotExist(err) {
		t.Errorf("outbox file still present after Receive: %v", err)
	}
}

// TestMailboxChannel_AgentsAreIsolated: file in agent A's outbox must
// not satisfy Receive for agent B.
func TestMailboxChannel_AgentsAreIsolated(t *testing.T) {
	dir := t.TempDir()
	ch := NewMailboxChannel(dir)
	defer ch.CloseAll()
	infoA, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}
	_, err = ch.Open("b")
	if err != nil {
		t.Fatal(err)
	}
	// Write only to A's outbox.
	if err := writeAtomic(t, infoA.Env["HARNESS_MAILBOX_PATH"], `{"text":"for-A"}`); err != nil {
		t.Fatal(err)
	}

	// A should see it.
	ctxA, cancelA := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancelA()
	got, err := ch.Receive(ctxA, "a")
	if err != nil {
		t.Fatal(err)
	}
	if got != `{"text":"for-A"}` {
		t.Errorf("A got %q", got)
	}

	// B should not.
	ctxB, cancelB := context.WithTimeout(context.Background(), 80*time.Millisecond)
	defer cancelB()
	if _, err := ch.Receive(ctxB, "b"); err == nil {
		t.Fatal("agent B saw A's payload — channels are not isolated")
	}
}

// TestMailboxChannel_IgnoresIncompleteTmpFiles: a *.tmp file should NOT
// satisfy Receive — only the final atomic-renamed *.json. This pins the
// safety contract the prompt promises ("Write atomically, harness only
// reads completed files").
//
// Implementation detail: the channel watches for `outbox.json` (the
// path returned in Env). A `outbox.json.tmp` sibling is part of the
// model's write-then-rename dance and must not be picked up.
func TestMailboxChannel_IgnoresIncompleteTmpFiles(t *testing.T) {
	dir := t.TempDir()
	ch := NewMailboxChannel(dir)
	defer ch.CloseAll()
	info, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}
	outboxPath := info.Env["HARNESS_MAILBOX_PATH"]
	tmpPath := outboxPath + ".tmp"

	// Write the .tmp but never rename. Receive must NOT pick it up.
	if err := os.WriteFile(tmpPath, []byte(`{"text":"incomplete"}`), 0o600); err != nil {
		t.Fatal(err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()
	if _, err := ch.Receive(ctx, "a"); err == nil {
		t.Fatal("Receive returned a .tmp file — atomic-rename safety violated")
	}
}

// TestMailboxChannel_CloseRemovesAgentDir: termination cleans up so a
// rerun with the same id doesn't see stale files.
func TestMailboxChannel_CloseRemovesAgentDir(t *testing.T) {
	dir := t.TempDir()
	ch := NewMailboxChannel(dir)
	defer ch.CloseAll()
	info, err := ch.Open("a")
	if err != nil {
		t.Fatal(err)
	}
	parent := filepath.Dir(info.Env["HARNESS_MAILBOX_PATH"])
	if err := ch.Close("a"); err != nil {
		t.Fatal(err)
	}
	if _, err := os.Stat(parent); !os.IsNotExist(err) {
		t.Errorf("outbox parent still present after Close: %v", err)
	}
}

// writeAtomic mimics what the model is told to do: write to a .tmp
// then rename. Used by tests to deliver payloads safely.
func writeAtomic(t *testing.T, path, body string) error {
	t.Helper()
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, []byte(body), 0o600); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}
