package main

import (
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"

	"reasonix/internal/agent"
	"reasonix/internal/control"
)

// The lease registry folds session paths through agent.CanonicalSessionPath
// (lowercased on Windows). Every desktop "same session" comparison must fold
// the same way, or a lease the tab itself holds looks foreign and every
// model/effort/token switch self-locks with the "already open in another
// window" error (#5999, #6006, #5996).

func TestSessionRuntimeKeyMatchesLeaseKey(t *testing.T) {
	dir := t.TempDir()
	// Mixed-case segment on every platform; on Windows the lease key folds it.
	path := filepath.Join(dir, "Sessions-Dir", "20260705-Test.jsonl")
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}

	lease, err := agent.TryAcquireSessionLease(path)
	if err != nil {
		t.Fatalf("acquire lease: %v", err)
	}
	defer lease.Release()

	if got, want := sessionRuntimeKey(lease.Path()), sessionRuntimeKey(path); got != want {
		t.Fatalf("lease path key %q != session path key %q; lease reuse checks will self-lock", got, want)
	}
}

func TestSessionRuntimeKeyIsIdempotent(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "Case-Mix", "20260705-Test.jsonl")
	key := sessionRuntimeKey(path)
	if key == "" {
		t.Fatal("empty key for non-empty path")
	}
	if again := sessionRuntimeKey(key); again != key {
		t.Fatalf("sessionRuntimeKey not idempotent: %q -> %q", key, again)
	}
}

func TestSessionRuntimeKeyFoldsCaseOnWindows(t *testing.T) {
	if runtime.GOOS != "windows" {
		t.Skip("case-insensitive path identity is Windows-only")
	}
	dir := t.TempDir()
	path := filepath.Join(dir, "Sessions", "20260705-Test.jsonl")
	upper := strings.ToUpper(path)
	if sessionRuntimeKey(path) != sessionRuntimeKey(upper) {
		t.Fatalf("case variants of one file produced distinct keys: %q vs %q",
			sessionRuntimeKey(path), sessionRuntimeKey(upper))
	}
}

func TestCanonicalTabSessionPathNormalizesOutsideSessionDir(t *testing.T) {
	// Project-scope sessions live outside config.SessionDir(); the fallback
	// must still clean the shape so one file cannot split into two keys.
	dir := t.TempDir()
	base := filepath.Join(dir, "projects", "p1", "sessions", "20260705-a.jsonl")
	messy := filepath.Join(dir, "projects", "p1", ".", "sessions") + string(filepath.Separator) + "20260705-a.jsonl"
	if sessionRuntimeKey(base) != sessionRuntimeKey(messy) {
		t.Fatalf("path shape variants split keys: %q vs %q",
			sessionRuntimeKey(base), sessionRuntimeKey(messy))
	}
}

func TestEnsureSessionLeaseReusesHeldLease(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "Sessions", "20260705-Reuse.jsonl")
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}

	tab := &WorkspaceTab{ID: "tab_reuse"}
	if err := tab.ensureSessionLease(path); err != nil {
		t.Fatalf("first ensure: %v", err)
	}
	defer tab.releaseSessionLease()

	acquires := 0
	sessionLeaseAcquireHookForTest = func() { acquires++ }
	defer func() { sessionLeaseAcquireHookForTest = nil }()

	// Same path again — and the lease's own (canonical) form: both must hit
	// the fast path instead of re-acquiring against our own registry entry.
	if err := tab.ensureSessionLease(path); err != nil {
		t.Fatalf("re-ensure same path: %v", err)
	}
	if err := tab.ensureSessionLease(tab.sessionLeaseRuntimeKey()); err != nil {
		t.Fatalf("re-ensure canonical form: %v", err)
	}
	if acquires != 0 {
		t.Fatalf("expected fast-path reuse, got %d new acquires", acquires)
	}
}

func TestCanReclaimAllowsMissingLeaseInfo(t *testing.T) {
	// lease.json deleted or torn: Info is nil but nothing actually holds the
	// session. The reclaim attempt must be allowed — the OS lock arbitrates —
	// instead of wedging the session as busy on missing metadata (#5999's
	// fresh-install variant).
	app := NewApp()
	tab := &WorkspaceTab{ID: "tab_nil_info"}
	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
	path := filepath.Join(t.TempDir(), "Sessions", "20260705-a.jsonl")

	errNil := &agent.SessionLeaseError{Path: path}
	if !app.canReclaimCurrentProcessSessionLease(tab, path, errNil) {
		t.Fatal("nil-Info lease error must allow a reclaim attempt")
	}

	foreign := &agent.SessionLeaseError{Path: path, Info: &agent.SessionLeaseInfo{
		SessionPath: path, WriterID: "other-host-1-deadbeef", PID: os.Getpid() + 1,
	}}
	if app.canReclaimCurrentProcessSessionLease(tab, path, foreign) {
		t.Fatal("foreign-holder lease error must not allow reclaim")
	}
}

func TestCanReclaimRejectsDetachedRuntimeOwner(t *testing.T) {
	app := NewApp()
	tab := &WorkspaceTab{ID: "visible"}
	app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
	path := filepath.Join(t.TempDir(), "Sessions", "detached-owner.jsonl")
	ctrl := control.New(control.Options{
		SessionDir:  filepath.Dir(path),
		SessionPath: path,
		Label:       "detached",
	})
	t.Cleanup(ctrl.Close)
	app.detachedSessions[sessionRuntimeKey(path)] = &WorkspaceTab{
		ID:          "detached",
		SessionPath: path,
		Ctrl:        ctrl,
	}
	err := &agent.SessionLeaseError{Path: path, Info: &agent.SessionLeaseInfo{
		SessionPath: path,
		WriterID:    agent.SessionWriterID(),
		PID:         os.Getpid(),
	}}

	if app.canReclaimCurrentProcessSessionLease(tab, path, err) {
		t.Fatal("detached runtime owner must block same-process lease reclaim")
	}
}

func TestSessionLeaseBusyErrorOmitsSettingClause(t *testing.T) {
	generic := (&sessionLeaseBusyError{}).Error()
	if strings.Contains(generic, "before changing") {
		t.Fatalf("empty-setting busy error still names a setting: %q", generic)
	}
	withSetting := (&sessionLeaseBusyError{setting: "model"}).Error()
	if !strings.Contains(withSetting, "before changing model") {
		t.Fatalf("setting busy error lost its clause: %q", withSetting)
	}
}
