package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sync"
	"testing"
	"time"

	"reasonix/internal/agent"
	"reasonix/internal/boot"
	"reasonix/internal/config"
	"reasonix/internal/event"
)

// TestSessionLeaseHelpersConcurrentAccess hammers the sessionLeaseMu helpers
// (ensure/take/adopt/release/key) from concurrent goroutines. Run with -race:
// any residual raw access to tab.sessionLease shows up as a data race, and the
// final acquire asserts no lease leaked through an interleaving.
func TestSessionLeaseHelpersConcurrentAccess(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	path := filepath.Join(dir, "lease-helper-hammer.jsonl")
	key := sessionRuntimeKey(path)
	tabA := &WorkspaceTab{ID: "a"}
	tabB := &WorkspaceTab{ID: "b"}

	const iterations = 300
	var wg sync.WaitGroup
	wg.Add(3)
	go func() {
		defer wg.Done()
		for range iterations {
			_ = tabA.ensureSessionLease(path)
			_ = tabA.sessionLeaseRuntimeKey()
		}
	}()
	go func() {
		defer wg.Done()
		for range iterations {
			// The applyRuntimeTab transfer shape: move A's lease to B and back.
			tabB.adoptSessionLease(tabA.takeSessionLease())
			tabA.adoptSessionLease(tabB.takeSessionLease())
		}
	}()
	go func() {
		defer wg.Done()
		for range iterations {
			tabA.releaseSessionLease()
		}
	}()
	wg.Wait()

	tabA.releaseSessionLease()
	tabB.releaseSessionLease()
	lease, err := agent.TryAcquireSessionLease(key)
	if err != nil {
		t.Fatalf("lease leaked through concurrent helper interleavings: %v", err)
	}
	lease.Release()
}

// TestDetachRuntimeForReplacementTransfersLease asserts the detach clone takes
// lease ownership through the locked helpers and the visible tab keeps none.
func TestDetachRuntimeForReplacementTransfersLease(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	path := filepath.Join(dir, "detach-transfer.jsonl")
	key := sessionRuntimeKey(path)
	tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: path}
	app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
	if err := tab.ensureSessionLease(path); err != nil {
		t.Fatalf("ensureSessionLease: %v", err)
	}
	t.Cleanup(func() {
		tab.releaseSessionLease()
		for _, d := range app.detachedSessions {
			d.releaseSessionLease()
		}
	})

	if !app.detachRuntimeForReplacement(tab) {
		t.Fatal("detachRuntimeForReplacement failed for a live tab")
	}
	detached := app.detachedSessions[key]
	if detached == nil {
		t.Fatal("detached runtime was not registered")
	}
	if got := detached.sessionLeaseRuntimeKey(); got != key {
		t.Fatalf("detached clone lease key = %q, want %q", got, key)
	}
	if got := tab.sessionLeaseRuntimeKey(); got != "" {
		t.Fatalf("visible tab still holds lease key %q after detach", got)
	}
}

// TestDetachRuntimeForReplacementSkipsRemovedTab: a tab that DeleteSession /
// CloseTab already unlinked must not be re-published into detachedSessions
// (the "session resurrects" class, #4384).
func TestDetachRuntimeForReplacementSkipsRemovedTab(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	path := filepath.Join(dir, "detach-removed.jsonl")

	removed := &WorkspaceTab{ID: "removed", SessionPath: path, removed: true}
	app := &App{tabs: map[string]*WorkspaceTab{"removed": removed}}
	if app.detachRuntimeForReplacement(removed) {
		t.Fatal("removed tab was detached into the background registry")
	}
	if len(app.detachedSessions) != 0 {
		t.Fatal("removed tab left an entry in detachedSessions")
	}

	orphan := &WorkspaceTab{ID: "orphan", SessionPath: path}
	if app.detachRuntimeForReplacement(orphan) {
		t.Fatal("tab absent from a.tabs was detached into the background registry")
	}
	if len(app.detachedSessions) != 0 {
		t.Fatal("orphan tab left an entry in detachedSessions")
	}
}

// TestTabEventSinkEmitConcurrentRebind: Emit keeps running on the controller
// goroutine while detach/reattach rebinds the sink's tab routing. Run with
// -race; before setBinding existed the tabID write raced every Emit.
func TestTabEventSinkEmitConcurrentRebind(t *testing.T) {
	sink := &tabEventSink{tabID: "before"}
	const iterations = 500
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		for range iterations {
			sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"})
		}
	}()
	go func() {
		defer wg.Done()
		for i := range iterations {
			sink.setBinding(fmt.Sprintf("tab-%d", i%2), nil)
			sink.clearContext()
		}
	}()
	wg.Wait()
	if tabID, _ := sink.binding(); tabID == "" {
		t.Fatal("sink lost its tab binding")
	}
}

func TestModeRebuildAndABANavigationFenceLeaseFailureAndOldEpochEvent(t *testing.T) {
	app, tab, oldCtrl, pathA, pathB, loadedB := newAtomicRebindTestApp(t)
	oldSink := tab.sink
	app.mu.RLock()
	epochA := app.sessionRuntimeViewLocked(tab).Epoch
	app.mu.RUnlock()

	restored := make(chan struct{})
	continueCommit := make(chan struct{})
	oldEvent := make(chan wireEventTab, 1)
	var capturedOldEvent wireEventTab
	oldSink.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
		if name != eventChannel || len(payload) != 1 {
			return
		}
		if wire, ok := payload[0].(wireEventTab); ok && wire.Text == "retired runtime event" {
			oldEvent <- wire
		}
	}
	var blockOnce sync.Once
	app.rebindCandidateHook = func(stage string) error {
		switch stage {
		case "restored":
			blockOnce.Do(func() {
				close(restored)
				<-continueCommit
			})
		case "committed":
			oldSink.Emit(event.Event{Kind: event.Notice, Text: "retired runtime event"})
			select {
			case capturedOldEvent = <-oldEvent:
			case <-time.After(5 * time.Second):
				return errors.New("retired runtime event was not emitted")
			}
		}
		return nil
	}

	rebindDone := make(chan error, 1)
	go func() {
		rebindDone <- app.rebindTabToLoadedSessionPath(tab, pathB, loadedB)
	}()
	select {
	case <-restored:
	case <-time.After(10 * time.Second):
		t.Fatal("A to B rebind did not reach restored candidate")
	}

	// Concurrent deprecated mode call must not interleave with the rebind
	// transaction or publish a half-updated profile.
	modeDone := make(chan error, 1)
	go func() {
		modeDone <- app.SetTokenModeForTab(tab.ID, boot.TokenModeFull)
	}()
	close(continueCommit)
	if err := <-rebindDone; err != nil {
		t.Fatalf("A to B rebind: %v", err)
	}
	if err := <-modeDone; err != nil {
		t.Fatalf("SetTokenModeForTab after A to B: %v", err)
	}

	stale := capturedOldEvent
	app.mu.RLock()
	epochB := app.sessionRuntimeViewLocked(tab).Epoch
	app.mu.RUnlock()
	if stale.RuntimeEpoch != epochA || epochB == "" || epochB == epochA {
		t.Fatalf("epoch fence stale=%q source=%q target=%q", stale.RuntimeEpoch, epochA, epochB)
	}
	if app.controllerForTab(tab) == oldCtrl ||
		sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(pathB) ||
		currentTabTokenMode(tab) != boot.TokenModeFull {
		t.Fatalf("A to B plus SetTokenModeForTab did not converge: ctrl=%p path=%q token=%q",
			app.controllerForTab(tab), tab.currentSessionPath(), currentTabTokenMode(tab))
	}

	// A is now free. Hold it as an external target, then attempt B to A. The
	// failed return leg must keep the rebuilt B controller, lease, path, epoch,
	// and full token profile unchanged.
	app.rebindCandidateHook = nil
	holderA, err := agent.TryAcquireSessionLease(pathA)
	if err != nil {
		t.Fatalf("hold A before return navigation: %v", err)
	}
	defer holderA.Release()
	loadedA, err := agent.LoadSession(pathA)
	if err != nil {
		t.Fatalf("load A: %v", err)
	}
	ctrlB := app.controllerForTab(tab)
	err = app.rebindTabToLoadedSessionPath(tab, pathA, loadedA)
	if !errors.Is(err, agent.ErrSessionLeaseHeld) {
		t.Fatalf("B to A error = %v, want ErrSessionLeaseHeld", err)
	}
	if app.controllerForTab(tab) != ctrlB ||
		sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(pathB) ||
		tab.sessionLeaseRuntimeKey() != sessionRuntimeKey(pathB) ||
		currentTabTokenMode(tab) != boot.TokenModeFull {
		t.Fatalf("failed B to A changed B runtime: ctrl=%p path=%q lease=%q token=%q",
			app.controllerForTab(tab), tab.currentSessionPath(), tab.sessionLeaseRuntimeKey(), currentTabTokenMode(tab))
	}
	app.mu.RLock()
	finalView := app.sessionRuntimeViewLocked(tab)
	targetAlias := app.runtimeBySessionKey[sessionRuntimeKey(pathA)]
	app.mu.RUnlock()
	if finalView.Phase != sessionRuntimeReady || finalView.Epoch != epochB || targetAlias != nil {
		t.Fatalf("failed B to A runtime = phase %q epoch %q aliasA=%#v, want ready/%q/no alias",
			finalView.Phase, finalView.Epoch, targetAlias, epochB)
	}
}

// TestTabBuildSupersededByRebindGeneration: a session rebind bumps
// buildGeneration to strand any in-flight async build, so its swap (and every
// mid-build field write) is rejected; synchronous rebuilds pass generation 0
// and rely on runtimeRebuildMu instead.
func TestTabBuildSupersededByRebindGeneration(t *testing.T) {
	tab := &WorkspaceTab{ID: "tab"}
	app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
	app.mu.Lock()
	tab.buildGeneration = 3
	app.mu.Unlock()
	if app.tabBuildSuperseded(tab, 3) {
		t.Fatal("build with the current generation must not be superseded")
	}
	app.mu.Lock()
	tab.buildGeneration++ // the rebind-side invalidation
	app.mu.Unlock()
	if !app.tabBuildSuperseded(tab, 3) {
		t.Fatal("stale-generation build must be superseded after a rebind bump")
	}
	if app.tabBuildSuperseded(tab, 0) {
		t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps")
	}
	app.mu.Lock()
	tab.removed = true
	app.mu.Unlock()
	if !app.tabBuildSuperseded(tab, 0) {
		t.Fatal("removed tab must supersede every build, including synchronous ones")
	}
}

// TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only
// release the lease bound to their own path; a mismatched key (the rebind's
// replacement session) must be left untouched.
func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	pathA := filepath.Join(dir, "lease-key-a.jsonl")
	pathB := filepath.Join(dir, "lease-key-b.jsonl")
	tab := &WorkspaceTab{ID: "tab"}
	t.Cleanup(tab.releaseSessionLease)
	if err := tab.ensureSessionLease(pathB); err != nil {
		t.Fatalf("ensureSessionLease: %v", err)
	}

	tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA))
	if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil {
		t.Fatal("mismatched key released the tab's live lease")
	}

	tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB))
	lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB))
	if err != nil {
		t.Fatalf("matching key did not release the lease: %v", err)
	}
	lease.Release()
}

// TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind
// interleaving from the #5968 review: a stale async build cleans up after a
// rebind's replacement build already published its own SharedHostKey and
// session lease on the same live tab. The stale build must drop only its own
// host reference and lease, never the tab's.
func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) {
	isolateDesktopUserDirs(t)
	dir := config.SessionDir()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir sessions: %v", err)
	}
	oldPath := filepath.Join(dir, "superseded-old.jsonl")
	newPath := filepath.Join(dir, "superseded-new.jsonl")
	newKey := sessionRuntimeKey(newPath)

	app := &App{tabs: map[string]*WorkspaceTab{}}
	// The stale build and the replacement build each hold one reference on
	// the same workspace-root host (the common same-root rebind).
	app.acquireSharedHost("root")
	app.acquireSharedHost("root")

	tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"}
	app.tabs["tab"] = tab
	t.Cleanup(tab.releaseSessionLease)
	if err := tab.ensureSessionLease(newPath); err != nil {
		t.Fatalf("ensureSessionLease(new): %v", err)
	}

	// Stale build abandons with ITS key material: the old lease path and the
	// root key it acquired.
	app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath))

	app.mu.RLock()
	hostKey := tab.SharedHostKey
	app.mu.RUnlock()
	if hostKey != "root" {
		t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey)
	}
	app.sharedHostsMu.Lock()
	entry := app.sharedHosts["root"]
	refs := 0
	if entry != nil {
		refs = entry.refs
	}
	app.sharedHostsMu.Unlock()
	if refs != 1 {
		t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs)
	}
	if got := tab.sessionLeaseRuntimeKey(); got != newKey {
		t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey)
	}

	// Removal shape: the abandoned build's own key still on the tab is the
	// last reference and must be released.
	app.abandonSupersededBuild(tab, nil, "", newKey)
	lease, err := agent.TryAcquireSessionLease(newKey)
	if err != nil {
		t.Fatalf("matching-key abandon did not release the lease: %v", err)
	}
	lease.Release()
}

// TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968
// review scenario: an async startTabControllerBuild is stalled right after
// binding its session lease (holding its controller, lease, and shared-host
// reference), a rebind to a different session starts meanwhile, and the
// stalled build then tries to finish. The rebind must bump the generation
// BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its
// superseded branches: the rebound controller must survive un-overwritten,
// the stale build's lease and host reference must be released, and the
// replacement build's ownership must stay intact.
func TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot(t *testing.T) {
	isolateDesktopUserDirs(t)
	root := globalTabWorkspaceRoot()
	dir := desktopSessionDir(root)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir session dir: %v", err)
	}
	oldPath := filepath.Join(dir, "rebind-race-old.jsonl")
	newPath := filepath.Join(dir, "rebind-race-new.jsonl")
	writeHistoryTestSession(t, oldPath, "old prompt")
	writeHistoryTestSession(t, newPath, "new prompt")
	oldKey := sessionRuntimeKey(oldPath)
	newKey := sessionRuntimeKey(newPath)

	app := NewApp()
	app.readyHook = func() {}
	tab := &WorkspaceTab{
		ID:            "tab",
		Scope:         "global",
		WorkspaceRoot: root,
		SessionPath:   oldPath,
		sink:          &tabEventSink{tabID: "tab", app: app},
		disabledMCP:   map[string]ServerView{},
	}
	app.tabs[tab.ID] = tab
	app.tabOrder = []string{tab.ID}
	app.activeTabID = tab.ID
	t.Cleanup(func() {
		app.mu.RLock()
		ctrl := tab.Ctrl
		app.mu.RUnlock()
		if ctrl != nil {
			ctrl.Close()
		}
		tab.releaseSessionLease()
	})

	// Stall the async build inside its lease bind: at that point it already
	// holds its controller, its lease, and its shared-host reference.
	stalled := make(chan struct{})
	releaseHook := make(chan struct{})
	var once sync.Once
	sessionLeaseAcquireHookForTest = func() {
		once.Do(func() {
			close(stalled)
			<-releaseHook
		})
	}
	t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
	t.Cleanup(func() {
		select {
		case <-releaseHook:
		default:
			close(releaseHook)
		}
	})

	// startTabControllerBuild only backgrounds the build when a Wails context
	// exists; expand its goroutine branch by hand so a.ctx can stay nil (the
	// nil-ctx emit guards are what every other build test relies on too).
	buildCtx, cancel := context.WithCancel(context.Background())
	app.mu.Lock()
	tab.buildGeneration++
	generation := tab.buildGeneration
	tab.buildCancel = cancel
	app.mu.Unlock()
	buildDone := make(chan struct{})
	go func() {
		defer close(buildDone)
		app.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
	}()
	select {
	case <-stalled:
	case <-time.After(15 * time.Second):
		t.Fatal("async build did not reach the lease bind")
	}

	loaded, err := agent.LoadSession(newPath)
	if err != nil {
		t.Fatalf("LoadSession(new): %v", err)
	}
	rebindErr := make(chan error, 1)
	go func() {
		rebindErr <- app.rebindTabToLoadedSessionPath(tab, newPath, loaded)
	}()

	// Wait until the rebind's validation section has invalidated the async
	// build (startTabControllerBuild set generation 1; the rebind bumps to 2)
	// while the async build is still stalled pre-swap.
	deadline := time.Now().Add(15 * time.Second)
	for {
		app.mu.RLock()
		generation := tab.buildGeneration
		app.mu.RUnlock()
		if generation >= 2 {
			break
		}
		if time.Now().After(deadline) {
			t.Fatal("rebind did not bump the build generation")
		}
		time.Sleep(time.Millisecond)
	}

	close(releaseHook)
	select {
	case err := <-rebindErr:
		if err != nil {
			t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
		}
	case <-time.After(30 * time.Second):
		t.Fatal("rebind did not finish after releasing the stalled build")
	}
	select {
	case <-buildDone:
	case <-time.After(30 * time.Second):
		t.Fatal("stale async build did not finish its superseded cleanup")
	}

	app.mu.RLock()
	builtCtrl := tab.Ctrl
	sessionPath := tab.SessionPath
	app.mu.RUnlock()
	if builtCtrl == nil {
		t.Fatal("rebind left the tab without a controller")
	}
	if got := builtCtrl.SessionPath(); got != newPath {
		t.Fatalf("stale async build overwrote the rebound controller: session path = %q, want %q", got, newPath)
	}
	if sessionPath != newPath {
		t.Fatalf("tab.SessionPath = %q, want %q", sessionPath, newPath)
	}
	if got := tab.sessionLeaseRuntimeKey(); got != newKey {
		t.Fatalf("tab lease key = %q, want the rebound session %q", got, newKey)
	}
	// The stale build's lease must be gone (released by its superseded
	// cleanup or replaced by the rebound build's adopt) — the old session
	// must be acquirable again.
	lease, err := agent.TryAcquireSessionLease(oldKey)
	if err != nil {
		t.Fatalf("stale build leaked its session lease: %v", err)
	}
	lease.Release()
	// Exactly one shared-host reference may remain: the rebound build's. The
	// stale build must have released its own.
	app.sharedHostsMu.Lock()
	refs := 0
	for _, entry := range app.sharedHosts {
		refs += entry.refs
	}
	app.sharedHostsMu.Unlock()
	if refs != 1 {
		t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the rebound build's)", refs)
	}
}

// TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot
// probe) while a fake build goroutine flips Ready/Label/StartupErr/model under
// a.mu — the write pattern of buildTabControllerWithContext. Run with -race.
func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
	isolateDesktopUserDirs(t)
	tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()}
	app := &App{
		tabs:        map[string]*WorkspaceTab{"tab": tab},
		tabOrder:    []string{"tab"},
		activeTabID: "tab",
	}

	const iterations = 100
	done := make(chan struct{})
	go func() {
		defer close(done)
		for i := range iterations {
			app.mu.Lock()
			tab.Ready = !tab.Ready
			tab.Label = fmt.Sprintf("model-%d", i)
			tab.StartupErr = ""
			tab.model = fmt.Sprintf("provider/m%d", i)
			tab.goal = fmt.Sprintf("goal-%d", i)
			app.mu.Unlock()
		}
	}()
	for range iterations {
		meta := app.MetaForTab("tab")
		if meta.EventChannel == "" {
			t.Fatal("MetaForTab returned zero meta for a live tab")
		}
	}
	<-done
}
