package main

import (
	"context"
	"crypto/sha256"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"syscall"
	"time"

	"reasonix/internal/agent"
	"reasonix/internal/config"
	"reasonix/internal/filelock"
	"reasonix/internal/fileutil"
	"reasonix/internal/store"
)

// sessions.go holds the desktop-only session-management state that the shared
// kernel doesn't model: custom display titles. A session on disk is just a JSONL
// transcript named by timestamp+model, with no title slot — so the history panel
// stores user-chosen names in a sidecar map (basename → title) next to the .jsonl
// files. The preview (first user message) is the default name; a title overrides
// it. Deleting a session also drops its title entry.

const sessionTitlesFile = ".titles.json"
const sessionDisplayFile = ".display.json"
const sessionPlannerDisplayFile = ".planner-display.json"
const sessionTrashDir = ".trash"
const sessionTrashMetaFile = ".trash-meta.json"

const (
	// Durable sidecar publication includes an fsync while holding the update
	// lock. Keep enough queue budget for a burst of in-process writers on
	// slower Windows disks, but fail external contention quickly so turn
	// completion and retry-queue handoff do not stall behind another process.
	sessionSidecarQueueTimeout        = 5 * time.Second
	sessionSidecarExternalLockTimeout = 750 * time.Millisecond
)

var (
	sessionTitlesQueueTimeout                = sessionSidecarQueueTimeout
	sessionPlannerDisplayExternalLockTimeout = sessionSidecarExternalLockTimeout
	sessionDisplayExternalLockTimeout        = sessionSidecarExternalLockTimeout
)

func sessionTitlesPath(dir string) string  { return filepath.Join(dir, sessionTitlesFile) }
func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) }
func sessionTrashPath(dir string) string   { return filepath.Join(dir, sessionTrashDir) }

func desktopSessionDir(root string) string {
	root = strings.TrimSpace(root)
	if root == "" {
		cwd, err := os.Getwd()
		if err != nil {
			return config.SessionDir()
		}
		root = cwd
	}
	if dir := config.ProjectSessionDir(root); dir != "" {
		return dir
	}
	return config.SessionDir()
}

func loadSessionTitlesForUpdate(dir string) (map[string]string, error) {
	return loadStringMapForUpdate(sessionTitlesPath(dir))
}

func updateSessionTitles(dir string, mutate func(map[string]string) bool) error {
	if strings.TrimSpace(dir) == "" {
		return errors.New("title directory is empty")
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	ctx, cancel := context.WithTimeout(context.Background(), sessionTitlesQueueTimeout)
	defer cancel()
	release, err := filelock.AcquireWithExternalTimeout(ctx, sessionTitlesPath(dir)+".lock", sessionSidecarExternalLockTimeout)
	if err != nil {
		return fmt.Errorf("lock title sidecar: %w", err)
	}
	defer release()

	m, err := loadSessionTitlesForUpdate(dir)
	if err != nil {
		return err
	}
	if !mutate(m) {
		return nil
	}
	return saveSessionTitles(dir, m)
}

// saveSessionTitles writes the map durably and atomically. Keep this on the
// shared helper so the temporary file is fsynced before it is published.
func saveSessionTitles(dir string, m map[string]string) error {
	b, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	return fileutil.AtomicWriteFile(sessionTitlesPath(dir), b, 0o600)
}

// setSessionTitle sets (or, with an empty title, clears) a session's custom name.
func setSessionTitle(dir, sessionPath, title string) error {
	sessionPath, _, err := validateSessionPath(dir, sessionPath)
	if err != nil {
		return err
	}
	key := filepath.Base(sessionPath)
	return updateSessionTitles(dir, func(m map[string]string) bool {
		title = strings.TrimSpace(title)
		if title == "" {
			if _, ok := m[key]; !ok {
				return false
			}
			delete(m, key)
			return true
		}
		if m[key] == title {
			return false
		}
		m[key] = title
		return true
	})
}

// deleteSessionFile moves a session's .jsonl and file sidecars into the local
// trash. Title/display sidecars stay in place so trash previews and restores can
// preserve the user's labels.
func deleteSessionFile(dir, sessionPath string) error {
	sessionPath, key, err := validateSessionPath(dir, sessionPath)
	if err != nil {
		return err
	}
	return trashSessionArtifacts(dir, sessionPath, key)
}

type trashedSessionMeta struct {
	Key       string `json:"key"`
	DeletedAt int64  `json:"deletedAt"`
}

type sessionTrashArtifact struct {
	src  string
	name string
}

func sessionTelemetryPath(sessionPath string) string {
	if strings.TrimSpace(sessionPath) == "" {
		return ""
	}
	return sessionPath + ".telemetry.json"
}

func sessionTrashArtifacts(sessionPath, key string) []sessionTrashArtifact {
	stem := strings.TrimSuffix(key, ".jsonl")
	return []sessionTrashArtifact{
		{src: sessionPath, name: key},
		{src: store.SessionMeta(sessionPath), name: key + ".meta"},
		{src: store.SessionGoalState(sessionPath), name: stem + ".goal-state.json"},
		{src: store.SessionEventLog(sessionPath), name: stem + ".events.jsonl"},
		{src: store.SessionEventLogDamaged(sessionPath), name: stem + ".events.jsonl.damaged"},
		{src: store.SessionEventIndex(sessionPath), name: stem + ".event-index.json"},
		{src: store.SessionDisplayIndex(sessionPath), name: stem + ".display-index.json"},
		{src: store.SessionConflictLog(sessionPath), name: stem + ".conflicts.jsonl"},
		{src: store.SessionRecoveryState(sessionPath), name: stem + ".recovery.json"},
		{src: sessionTelemetryPath(sessionPath), name: key + ".telemetry.json"},
		{src: store.SessionCheckpointDir(sessionPath), name: stem + ".ckpt"},
		{src: store.SessionJobsDir(sessionPath), name: stem + ".jobs"},
		{src: store.SessionInboxDir(sessionPath), name: stem + ".inbox"},
	}
}

// errSessionBusyElsewhere is the sanitized error surfaced when a destructive
// session operation is blocked by a live owner. It intentionally carries no
// writer id, hostname, or path.
var errSessionBusyElsewhere = errors.New("session is in use by another Reasonix window or process")

// acquireSessionRemovalGuard wraps agent.TryAcquireSessionRemovalGuard with
// the sanitized busy error. The guard holds the session's save and lease
// locks across the destructive operation and deletes the lock files
// atomically with the release — a one-shot busy probe followed by RemoveAll
// would let another process acquire the lease in between and then lose its
// freshly locked lease file, breaking cross-process mutual exclusion.
func acquireSessionRemovalGuard(sessionPath string) (*agent.SessionRemovalGuard, error) {
	guard, err := withSessionLeaseContentionRetry(func() (*agent.SessionRemovalGuard, error) {
		return agent.TryAcquireSessionRemovalGuard(sessionPath)
	})
	if err != nil {
		if errors.Is(err, agent.ErrSessionLeaseHeld) {
			return nil, errSessionBusyElsewhere
		}
		return nil, err
	}
	return guard, nil
}

func sessionOwnedArtifactPaths(sessionPath string) []string {
	key := filepath.Base(sessionPath)
	artifacts := sessionTrashArtifacts(sessionPath, key)
	paths := make([]string, 0, len(artifacts))
	for _, artifact := range artifacts {
		if strings.TrimSpace(artifact.src) != "" {
			paths = append(paths, artifact.src)
		}
	}
	return paths
}

func trashSessionArtifacts(dir, sessionPath, key string) error {
	return trashSessionArtifactsBeforeMove(dir, sessionPath, key, nil)
}

func reconcileDesktopCleanupPending(dir string) error {
	return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error {
		if strings.TrimSpace(item.Meta.Operation) == "delete" {
			sessionPath, key, err := validateSessionPath(dir, item.SessionPath)
			if err != nil {
				return err
			}
			return reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key)
		}
		return removeDesktopSessionArtifacts(item.SessionPath)
	})
}

func validateSessionTrashTarget(dir, sessionPath, key string) error {
	if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
		return nil
	} else if err != nil {
		return err
	}
	itemDir := filepath.Join(sessionTrashPath(dir), key)
	if info, err := os.Stat(itemDir); err == nil {
		if !info.IsDir() {
			return fmt.Errorf("session trash target is not a directory: %s", key)
		}
		trashPath := filepath.Join(itemDir, key)
		if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
			removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
			if err != nil {
				return err
			}
			if removable {
				return nil
			}
			if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
				return errSessionBusyElsewhere
			}
			return nil
		} else if err != nil && !os.IsNotExist(err) {
			return err
		}
		return nil
	} else if !os.IsNotExist(err) {
		return err
	}
	return nil
}

type preparedSessionTrashTarget struct {
	shouldMove     bool
	itemDir        string
	allocateUnique bool
}

func prepareSessionTrashTarget(dir, sessionPath, key string) (preparedSessionTrashTarget, error) {
	if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
		return preparedSessionTrashTarget{}, nil
	} else if err != nil {
		return preparedSessionTrashTarget{}, err
	}
	itemDir := filepath.Join(sessionTrashPath(dir), key)
	if info, err := os.Stat(itemDir); err == nil {
		if !info.IsDir() {
			return preparedSessionTrashTarget{}, fmt.Errorf("session trash target is not a directory: %s", key)
		}
		trashPath := filepath.Join(itemDir, key)
		if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
			removable, err := liveSessionRemovableWithExistingTrash(sessionPath, trashPath)
			if err != nil {
				return preparedSessionTrashTarget{}, err
			}
			if removable {
				return preparedSessionTrashTarget{}, removeDesktopSessionArtifacts(sessionPath)
			}
			if agent.SessionLeaseHeldByOtherRuntime(sessionPath) {
				return preparedSessionTrashTarget{}, errSessionBusyElsewhere
			}
			return preparedSessionTrashTarget{shouldMove: true, allocateUnique: true}, nil
		} else if err != nil && !os.IsNotExist(err) {
			return preparedSessionTrashTarget{}, err
		}
		if err := os.RemoveAll(itemDir); err != nil {
			return preparedSessionTrashTarget{}, err
		}
	} else if !os.IsNotExist(err) {
		return preparedSessionTrashTarget{}, err
	}
	return preparedSessionTrashTarget{shouldMove: true, itemDir: itemDir}, nil
}

func reserveUniqueSessionTrashItemDir(dir, key string) (string, error) {
	root := sessionTrashPath(dir)
	if err := os.MkdirAll(root, 0o755); err != nil {
		return "", err
	}
	stem := strings.TrimSuffix(key, ".jsonl")
	for i := range 100 {
		name := fmt.Sprintf("%s.jsonl-deleted-%d-%02d", stem, time.Now().UnixNano(), i)
		itemDir := filepath.Join(root, name)
		if err := os.Mkdir(itemDir, 0o755); err == nil {
			return itemDir, nil
		} else if !os.IsExist(err) {
			return "", err
		}
	}
	return "", fmt.Errorf("could not allocate unique trash target for session: %s", key)
}

// liveSessionRemovableWithExistingTrash reports whether a live session file may
// be removed even though a trash copy already exists under the same key: the
// live file must be discardable (empty stub) or byte-identical to the trash
// copy, and no other runtime may hold its session lease — another process could
// be mid-write, and removing the file would silently drop its next save.
func liveSessionRemovableWithExistingTrash(sessionPath, trashPath string) (bool, error) {
	discardable, err := liveSessionDiscardable(sessionPath)
	if err != nil {
		return false, err
	}
	duplicate := false
	if !discardable {
		duplicate, err = trashSessionMatchesLive(sessionPath, trashPath)
		if err != nil {
			return false, err
		}
	}
	if !discardable && !duplicate {
		return false, nil
	}
	return !agent.SessionLeaseHeldByOtherRuntime(sessionPath), nil
}

func liveSessionDiscardable(sessionPath string) (bool, error) {
	if agent.IsCleanupPending(sessionPath) {
		return true, nil
	}
	return liveSessionContentDiscardable(sessionPath)
}

func liveSessionContentDiscardable(sessionPath string) (bool, error) {
	info, err := os.Stat(sessionPath)
	if os.IsNotExist(err) {
		return true, nil
	}
	if err != nil {
		return false, err
	}
	if info.IsDir() {
		return false, nil
	}
	if info.Size() == 0 {
		return true, nil
	}
	session, err := agent.LoadSession(sessionPath)
	if err != nil {
		return false, nil
	}
	return !session.HasContent(), nil
}

func trashSessionMatchesLive(sessionPath, trashPath string) (bool, error) {
	if _, err := os.Stat(sessionPath); err != nil {
		if os.IsNotExist(err) {
			return true, nil
		}
		return false, err
	}
	// Compare decoded transcripts, not .jsonl bytes: the checkpoint only
	// changes at checkpoints, so two byte-identical .jsonl files can hide
	// diverged event logs — and treating them as duplicates would delete the
	// live session's newer history.
	return agent.SessionsShareContent(sessionPath, trashPath)
}

func sessionFileHasConversationContent(sessionPath string) bool {
	if strings.TrimSpace(sessionPath) == "" || agent.IsCleanupPending(sessionPath) {
		return false
	}
	info, err := os.Stat(sessionPath)
	if err != nil || info.IsDir() || info.Size() == 0 {
		return false
	}
	session, err := agent.LoadSession(sessionPath)
	if err != nil {
		return false
	}
	return session.HasContent()
}

func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove func()) error {
	if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
		return err
	}
	target, err := prepareSessionTrashTarget(dir, sessionPath, key)
	if err != nil {
		return err
	}
	if !target.shouldMove {
		return agent.ClearCleanupPending(sessionPath)
	}
	// Acquired after prepareSessionTrashTarget: the duplicate-trash path in
	// there takes its own removal guard, and the guard is not reentrant.
	guard, err := acquireSessionRemovalGuard(sessionPath)
	if err != nil {
		return err
	}
	defer guard.Release()
	if err := invalidateTopicDirMarkers(dir); err != nil {
		return err
	}
	itemDir := target.itemDir
	if target.allocateUnique {
		itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
		if err != nil {
			return err
		}
	} else if err := os.MkdirAll(itemDir, 0o755); err != nil {
		return err
	}
	if beforeMove != nil {
		beforeMove()
	}
	for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
		if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil {
			return err
		}
	}
	if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
		return err
	}
	if err := guard.RemoveSidecarsAndRelease(); err != nil {
		return err
	}
	meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
	b, err := json.MarshalIndent(meta, "", "  ")
	if err != nil {
		return err
	}
	if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
		return err
	}
	if err := agent.ClearCleanupPending(sessionPath); err != nil {
		return err
	}
	return nil
}

func listTrashedSessionFiles(dir string) ([]string, error) {
	root := sessionTrashPath(dir)
	entries, err := os.ReadDir(root)
	if err != nil {
		if os.IsNotExist(err) {
			return []string{}, nil
		}
		return nil, err
	}
	paths := []string{}
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		itemDir := filepath.Join(root, e.Name())
		keys := []string{}
		if b, err := readFileUTF8(filepath.Join(itemDir, sessionTrashMetaFile)); err == nil {
			var meta trashedSessionMeta
			if json.Unmarshal(b, &meta) == nil && store.IsSessionTranscriptName(meta.Key) {
				keys = append(keys, meta.Key)
			}
		}
		if store.IsSessionTranscriptName(e.Name()) {
			keys = append(keys, e.Name())
		}
		for _, key := range keys {
			path := filepath.Join(itemDir, key)
			validPath, _, _, err := validateTrashedSessionPath(dir, path)
			if err != nil {
				continue
			}
			if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
				paths = append(paths, validPath)
				break
			}
		}
	}
	return paths, nil
}

func trashedSessionDeletedAt(path string) int64 {
	b, err := readFileUTF8(filepath.Join(filepath.Dir(path), sessionTrashMetaFile))
	if err != nil {
		return 0
	}
	var meta trashedSessionMeta
	if err := json.Unmarshal(b, &meta); err != nil {
		return 0
	}
	return meta.DeletedAt
}

func purgeTrashedSessionFile(dir, path string) error {
	_, key, itemDir, err := validateTrashedSessionPath(dir, path)
	if err != nil {
		return err
	}
	if err := os.RemoveAll(itemDir); err != nil {
		return err
	}
	if err := updateSessionTitles(dir, func(m map[string]string) bool {
		if _, ok := m[key]; !ok {
			return false
		}
		delete(m, key)
		return true
	}); err != nil {
		return err
	}
	if err := removeSessionDisplayKey(dir, key); err != nil {
		return err
	}
	if err := removeSessionPlannerDisplay(dir, key); err != nil {
		return err
	}
	return nil
}

func movePathIfExists(src, dst string) error {
	if _, err := os.Lstat(src); os.IsNotExist(err) {
		return nil
	} else if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	// Try os.Rename first — it's atomic and fast when it works.
	if err := os.Rename(src, dst); err == nil {
		return nil
	} else if sourcePathMissing(src) {
		return nil
	} else if !isRenameCrossDeviceOrBusy(err) {
		return err
	}
	// Fallback: copy then remove. This handles cross-device moves and the
	// Windows case where a directory rename fails because a handle is briefly
	// held open (e.g. antivirus scan, indexing, or a just-closed file).
	return copyAndRemove(src, dst)
}

// isRenameCrossDeviceOrBusy reports whether err is a cross-device rename or
// a "file busy" error that a copy+remove fallback can recover from.
func isRenameCrossDeviceOrBusy(err error) bool {
	if err == nil {
		return false
	}
	// Cross-device link.
	le := &os.LinkError{}
	if errors.As(err, &le) {
		if errors.Is(le.Err, syscall.EXDEV) {
			return true
		}
		// Windows: "The process cannot access the file because it is being used by another process."
		var errno syscall.Errno
		if errors.As(le.Err, &errno) {
			return errno == 32 // ERROR_SHARING_VIOLATION
		}
	}
	return false
}

func sourcePathMissing(src string) bool {
	if strings.TrimSpace(src) == "" {
		return true
	}
	_, err := os.Lstat(src)
	return os.IsNotExist(err)
}

// copyPathFn is a seam for tests to simulate a source vanishing mid-copy.
var copyPathFn = copyPath

// copyAndRemove recursively copies src to dst, then removes src. Used as a
// fallback when os.Rename fails (cross-device or Windows file-lock races).
func copyAndRemove(src, dst string) error {
	if err := copyPathFn(src, dst); err != nil {
		if sourcePathMissing(src) {
			// The source vanished mid-copy; drop the partial destination so
			// the trash never keeps a truncated artifact that a later restore
			// would resurrect as a corrupted transcript.
			_ = os.RemoveAll(dst)
			return nil
		}
		return err
	}
	// On Windows, wait briefly for any file handle release.
	time.Sleep(10 * time.Millisecond)
	return os.RemoveAll(src)
}

func copyPath(src, dst string) error {
	info, err := os.Lstat(src)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	mode := info.Mode()
	switch {
	case mode&os.ModeSymlink != 0:
		return copySymlink(src, dst)
	case mode.IsDir():
		return copyDir(src, dst, mode.Perm())
	case mode.IsRegular():
		return copyFile(src, dst, mode.Perm())
	default:
		return fmt.Errorf("unsupported file type in rename fallback: %s", src)
	}
}

func copyDir(src, dst string, mode os.FileMode) error {
	if err := os.MkdirAll(dst, mode); err != nil {
		return err
	}
	entries, err := os.ReadDir(src)
	if err != nil {
		if os.IsNotExist(err) {
			_ = os.RemoveAll(dst)
			return nil
		}
		return err
	}
	for _, e := range entries {
		srcPath := filepath.Join(src, e.Name())
		dstPath := filepath.Join(dst, e.Name())
		if err := copyPath(srcPath, dstPath); err != nil {
			return err
		}
	}
	return nil
}

func copyFile(src, dst string, mode os.FileMode) error {
	// Open source file.
	in, err := os.Open(src)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	// Create destination file.
	out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
	if err != nil {
		in.Close()
		return err
	}
	// Copy content.
	_, err = io.Copy(out, in)
	// Close both files before any removal.
	closeErr := out.Close()
	in.Close()
	if err != nil {
		return err
	}
	if closeErr != nil {
		return closeErr
	}
	return nil
}

func copySymlink(src, dst string) error {
	target, err := os.Readlink(src)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	return os.Symlink(target, dst)
}

func trashSubagentArtifacts(dir, sessionPath, itemDir string) error {
	artifacts, err := agent.ListSubagentsByParent(dir, agent.BranchID(sessionPath))
	if err != nil {
		return err
	}
	trashSubagentDir := filepath.Join(itemDir, "subagents")
	for _, artifact := range artifacts {
		paths := []string{artifact.SessionPath, artifact.MetaPath}
		paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
		for _, src := range paths {
			if strings.TrimSpace(src) == "" {
				continue
			}
			if err := movePathIfExists(src, filepath.Join(trashSubagentDir, filepath.Base(src))); err != nil {
				return err
			}
		}
	}
	return nil
}

func checkRestoreSubagentConflicts(dir, itemDir string) error {
	trashSubagentDir := filepath.Join(itemDir, "subagents")
	entries, err := os.ReadDir(trashSubagentDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}
		target := filepath.Join(dir, "subagents", entry.Name())
		if _, err := os.Stat(target); err == nil {
			return fmt.Errorf("subagent artifact already exists: %s", entry.Name())
		} else if !os.IsNotExist(err) {
			return err
		}
	}
	return nil
}

func restoreSubagentArtifacts(dir, itemDir string) error {
	trashSubagentDir := filepath.Join(itemDir, "subagents")
	entries, err := os.ReadDir(trashSubagentDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}
		if err := movePathIfExists(filepath.Join(trashSubagentDir, entry.Name()), filepath.Join(dir, "subagents", entry.Name())); err != nil {
			return err
		}
	}
	return nil
}

func validateSessionPath(dir, sessionPath string) (string, string, error) {
	if strings.TrimSpace(sessionPath) == "" {
		return "", "", fmt.Errorf("empty session path")
	}
	absDir, err := filepath.Abs(dir)
	if err != nil {
		return "", "", err
	}
	path := sessionPath
	if !filepath.IsAbs(path) {
		path = filepath.Join(absDir, path)
	}
	absPath, err := filepath.Abs(path)
	if err != nil {
		return "", "", err
	}
	if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
		return "", "", fmt.Errorf("not a session file: %s", sessionPath)
	}
	rel, err := filepath.Rel(absDir, absPath)
	if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
		return "", "", fmt.Errorf("session path outside session dir: %s", sessionPath)
	}
	if info, err := os.Lstat(absPath); err == nil {
		if info.IsDir() {
			return "", "", fmt.Errorf("not a session file: %s", sessionPath)
		}
		realDir, dirErr := filepath.EvalSymlinks(absDir)
		if dirErr != nil {
			realDir = absDir
		}
		realPath, err := filepath.EvalSymlinks(absPath)
		if err != nil {
			return "", "", err
		}
		rel, err := filepath.Rel(realDir, realPath)
		if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
			return "", "", fmt.Errorf("session path escapes session dir: %s", sessionPath)
		}
	} else if !os.IsNotExist(err) {
		return "", "", err
	}
	return absPath, filepath.Base(absPath), nil
}

func validateTrashedSessionPath(dir, sessionPath string) (string, string, string, error) {
	if strings.TrimSpace(sessionPath) == "" {
		return "", "", "", fmt.Errorf("empty session path")
	}
	root, err := filepath.Abs(sessionTrashPath(dir))
	if err != nil {
		return "", "", "", err
	}
	path := sessionPath
	if !filepath.IsAbs(path) {
		path = filepath.Join(root, path)
	}
	absPath, err := filepath.Abs(path)
	if err != nil {
		return "", "", "", err
	}
	if !store.IsSessionTranscriptName(filepath.Base(absPath)) {
		return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
	}
	rel, err := filepath.Rel(root, absPath)
	if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
		return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath)
	}
	parts := strings.Split(rel, string(filepath.Separator))
	if len(parts) != 2 {
		return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
	}
	if parts[0] != parts[1] {
		b, err := readFileUTF8(filepath.Join(root, parts[0], sessionTrashMetaFile))
		if err != nil {
			return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
		}
		var meta trashedSessionMeta
		if err := json.Unmarshal(b, &meta); err != nil || meta.Key != parts[1] {
			return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
		}
	}
	if info, err := os.Lstat(absPath); err == nil {
		if info.IsDir() {
			return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
		}
		realRoot, dirErr := filepath.EvalSymlinks(root)
		if dirErr != nil {
			realRoot = root
		}
		realPath, err := filepath.EvalSymlinks(absPath)
		if err != nil {
			return "", "", "", err
		}
		rel, err := filepath.Rel(realRoot, realPath)
		if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
			return "", "", "", fmt.Errorf("session path escapes trash dir: %s", sessionPath)
		}
	} else if !os.IsNotExist(err) {
		return "", "", "", err
	}
	return absPath, filepath.Base(absPath), filepath.Dir(absPath), nil
}

type sessionDisplayMap map[string]map[string]string

type sessionPlannerDisplayMap map[string][]plannerDisplayTurn

type plannerDisplayTurn struct {
	TurnID   string           `json:"turnId,omitempty"`
	UserHash string           `json:"userHash"`
	Messages []HistoryMessage `json:"messages"`
}

var errCorruptSessionPlannerDisplay = errors.New("corrupt planner display sidecar")

// sessionPlannerDisplayUpdateAfterLoad is a subprocess-test seam. Production
// leaves it nil; tests use it to force two independent processes into the old
// stale read-modify-write window without relying on scheduler timing.
var sessionPlannerDisplayUpdateAfterLoad func()

func messageDisplayKey(content string) string {
	sum := sha256.Sum256([]byte(content))
	return fmt.Sprintf("%x", sum[:])
}

func loadSessionDisplays(dir string) sessionDisplayMap {
	m := sessionDisplayMap{}
	b, err := readFileUTF8(sessionDisplayPath(dir))
	if err != nil {
		return m
	}
	_ = json.Unmarshal(b, &m)
	return m
}

func sessionPlannerDisplayPath(dir string) string {
	return filepath.Join(dir, sessionPlannerDisplayFile)
}

func loadSessionPlannerDisplays(dir string) sessionPlannerDisplayMap {
	m := sessionPlannerDisplayMap{}
	if strings.TrimSpace(dir) == "" {
		return m
	}
	b, err := readFileUTF8(sessionPlannerDisplayPath(dir))
	if err != nil {
		return m
	}
	_ = json.Unmarshal(b, &m)
	return m
}

func loadSessionPlannerDisplaysForUpdate(dir string) (sessionPlannerDisplayMap, error) {
	m := sessionPlannerDisplayMap{}
	b, err := readFileUTF8(sessionPlannerDisplayPath(dir))
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return m, nil
		}
		return nil, err
	}
	if err := json.Unmarshal(b, &m); err != nil {
		return nil, fmt.Errorf("%w: %w", errCorruptSessionPlannerDisplay, err)
	}
	if m == nil {
		m = sessionPlannerDisplayMap{}
	}
	return m, nil
}

func saveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error {
	b, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	return fileutil.AtomicWriteFile(sessionPlannerDisplayPath(dir), b, 0o600)
}

func saveOrRemoveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error {
	if len(m) == 0 {
		err := os.Remove(sessionPlannerDisplayPath(dir))
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	return saveSessionPlannerDisplays(dir, m)
}

func updateSessionPlannerDisplays(dir string, recoverCorrupt bool, mutate func(sessionPlannerDisplayMap) bool) error {
	if strings.TrimSpace(dir) == "" {
		return errors.New("planner display directory is empty")
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	ctx, cancel := context.WithTimeout(context.Background(), sessionSidecarQueueTimeout)
	defer cancel()
	release, err := filelock.AcquireWithExternalTimeout(ctx, sessionPlannerDisplayPath(dir)+".lock", sessionPlannerDisplayExternalLockTimeout)
	if err != nil {
		return fmt.Errorf("lock planner display sidecar: %w", err)
	}
	defer release()

	m, err := loadSessionPlannerDisplaysForUpdate(dir)
	if err != nil {
		if !recoverCorrupt || !errors.Is(err, errCorruptSessionPlannerDisplay) {
			return err
		}
		// A corrupt shared map cannot be edited safely. Destructive cleanup is
		// allowed to retire the unreadable sidecar so deleted-session display
		// data does not linger and later records can start from a valid map.
		if removeErr := os.Remove(sessionPlannerDisplayPath(dir)); removeErr != nil && !os.IsNotExist(removeErr) {
			return errors.Join(err, removeErr)
		}
		m = sessionPlannerDisplayMap{}
	}
	if sessionPlannerDisplayUpdateAfterLoad != nil {
		sessionPlannerDisplayUpdateAfterLoad()
	}
	if !mutate(m) {
		return nil
	}
	return saveOrRemoveSessionPlannerDisplays(dir, m)
}

func recordSessionPlannerDisplay(dir, sessionPath, userContent string, messages []HistoryMessage) error {
	return recordSessionPlannerDisplayForTurn(dir, sessionPath, "", userContent, messages)
}

func recordSessionPlannerDisplayForTurn(dir, sessionPath, turnID, userContent string, messages []HistoryMessage) error {
	if strings.TrimSpace(sessionPath) == "" || strings.TrimSpace(userContent) == "" || len(messages) == 0 {
		return nil
	}
	key := filepath.Base(sessionPath)
	turn := plannerDisplayTurn{
		TurnID:   strings.TrimSpace(turnID),
		UserHash: messageDisplayKey(userContent),
		Messages: cloneHistoryMessages(messages),
	}
	return updateSessionPlannerDisplays(dir, false, func(m sessionPlannerDisplayMap) bool {
		if turn.TurnID != "" {
			for i := range m[key] {
				if m[key][i].TurnID == turn.TurnID {
					m[key][i] = turn
					return true
				}
			}
		}
		m[key] = append(m[key], turn)
		return true
	})
}

func removeSessionPlannerDisplay(dir, sessionPath string) error {
	if strings.TrimSpace(sessionPath) == "" {
		return nil
	}
	key := filepath.Base(sessionPath)
	return updateSessionPlannerDisplays(dir, true, func(m sessionPlannerDisplayMap) bool {
		if _, ok := m[key]; !ok {
			return false
		}
		delete(m, key)
		return true
	})
}

func pruneSessionPlannerDisplays(dir string, protected map[string]struct{}) error {
	return updateSessionPlannerDisplays(dir, true, func(m sessionPlannerDisplayMap) bool {
		changed := false
		for key := range m {
			if sessionDisplayKeyStillOwned(dir, key, protected) {
				continue
			}
			delete(m, key)
			changed = true
		}
		return changed
	})
}

func sessionPlannerDisplayTurns(dir, sessionPath string) []plannerDisplayTurn {
	if strings.TrimSpace(dir) == "" || strings.TrimSpace(sessionPath) == "" {
		return nil
	}
	turns := loadSessionPlannerDisplays(dir)[filepath.Base(sessionPath)]
	if len(turns) == 0 {
		return nil
	}
	out := make([]plannerDisplayTurn, 0, len(turns))
	for _, turn := range turns {
		if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 {
			continue
		}
		out = append(out, plannerDisplayTurn{
			TurnID:   turn.TurnID,
			UserHash: turn.UserHash,
			Messages: cloneHistoryMessages(turn.Messages),
		})
	}
	return out
}

func saveSessionDisplays(dir string, m sessionDisplayMap) error {
	b, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	return fileutil.AtomicWriteFile(sessionDisplayPath(dir), b, 0o600)
}

func saveOrRemoveSessionDisplays(dir string, m sessionDisplayMap) error {
	if len(m) == 0 {
		err := os.Remove(sessionDisplayPath(dir))
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	return saveSessionDisplays(dir, m)
}

// updateSessionDisplays serializes the display sidecar's read-modify-write
// cycle. Parallel tabs can record display text concurrently; atomic rename
// protects readers from partial JSON but cannot prevent the last writer from
// replacing another tab's freshly added keys (#6873).
func updateSessionDisplays(dir string, mutate func(sessionDisplayMap) bool) error {
	if strings.TrimSpace(dir) == "" {
		return errors.New("display directory is empty")
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	ctx, cancel := context.WithTimeout(context.Background(), sessionSidecarQueueTimeout)
	defer cancel()
	release, err := filelock.AcquireWithExternalTimeout(ctx, sessionDisplayPath(dir)+".lock", sessionDisplayExternalLockTimeout)
	if err != nil {
		return fmt.Errorf("lock display sidecar: %w", err)
	}
	defer release()

	m := loadSessionDisplays(dir)
	if !mutate(m) {
		return nil
	}
	return saveOrRemoveSessionDisplays(dir, m)
}

func removeSessionDisplayKey(dir, key string) error {
	key = strings.TrimSpace(key)
	if key == "" {
		return nil
	}
	return updateSessionDisplays(dir, func(m sessionDisplayMap) bool {
		if m[key] == nil {
			return false
		}
		delete(m, key)
		return true
	})
}

func removeSessionDisplay(dir, sessionPath string) error {
	if strings.TrimSpace(sessionPath) == "" {
		return nil
	}
	return removeSessionDisplayKey(dir, filepath.Base(sessionPath))
}

func pruneSessionDisplays(dir string, protected map[string]struct{}) error {
	return updateSessionDisplays(dir, func(m sessionDisplayMap) bool {
		if len(m) == 0 {
			return false
		}
		changed := false
		for key := range m {
			if sessionDisplayKeyStillOwned(dir, key, protected) {
				continue
			}
			delete(m, key)
			changed = true
		}
		return changed
	})
}

func sessionDisplayKeyStillOwned(dir, key string, protected map[string]struct{}) bool {
	key = strings.TrimSpace(key)
	if key == "" || filepath.Base(key) != key || !store.IsSessionTranscriptName(key) {
		return false
	}
	if protected != nil {
		if _, ok := protected[key]; ok {
			return true
		}
	}
	sessionPath := filepath.Join(dir, key)
	if info, err := os.Stat(sessionPath); err == nil && !info.IsDir() {
		return true
	}
	trashPath := filepath.Join(sessionTrashPath(dir), key, key)
	if info, err := os.Stat(trashPath); err == nil && !info.IsDir() {
		return true
	}
	if paths, err := listTrashedSessionFiles(dir); err == nil {
		for _, path := range paths {
			if filepath.Base(path) == key {
				return true
			}
		}
	}
	return false
}

func recordSessionDisplay(dir, sessionPath, content, display string) error {
	if strings.TrimSpace(sessionPath) == "" || content == display || strings.TrimSpace(display) == "" {
		return nil
	}
	return updateSessionDisplays(dir, func(m sessionDisplayMap) bool {
		key := filepath.Base(sessionPath)
		if m[key] == nil {
			m[key] = map[string]string{}
		}
		m[key][messageDisplayKey(content)] = display
		return true
	})
}

// sessionDisplayResolver loads the sidecar once and returns a per-message
// resolver, so a transcript of N messages doesn't re-read .display.json N times.
func sessionDisplayResolver(dir, sessionPath string) func(content string) string {
	return sessionDisplayResolverFromMap(loadSessionDisplays(dir), sessionPath)
}

func sessionDisplayResolverFromMap(displays sessionDisplayMap, sessionPath string) func(content string) string {
	byHash := displays[filepath.Base(sessionPath)]
	return func(content string) string {
		if byHash != nil {
			if display := byHash[messageDisplayKey(content)]; strings.TrimSpace(display) != "" {
				return display
			}
		}
		return historyReplayUserContent(content)
	}
}

func resolveSessionDisplay(dir, sessionPath, content string) string {
	return sessionDisplayResolver(dir, sessionPath)(content)
}
