package claudecode

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

	"github.com/chenhg5/cc-connect/core"
)

func init() {
	core.RegisterAgent("claudecode", New)
}

// Agent drives Claude Code CLI using --input-format stream-json
// and --permission-prompt-tool stdio for bidirectional communication.
//
// Permission modes (maps to Claude's --permission-mode):
//   - "default":           every tool call requires user approval
//   - "acceptEdits":       auto-approve file edit tools, ask for others
//   - "plan":              plan only, no execution until approved
//   - "auto":              Claude's automatic permission classifier
//   - "bypassPermissions": auto-approve everything (alias: yolo)
type Agent struct {
	workDir          string
	cmd              string   // CLI binary name (default: "claude")
	cliExtraArgs     []string // extra args parsed from cmd (e.g. ["code", "-t", "foo"])
	configEnv        []string // env vars from [projects.agent.options.env] — persists across SetSessionEnv calls
	cmdArgsFlag      string   // if set, claude args are passed as a single string via this flag (e.g. "-a")
	model            string
	reasoningEffort  string // "low" | "medium" | "high" | "max"
	mode             string // "default" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | "dontAsk"
	allowedTools     []string
	disallowedTools  []string
	maxContextTokens int // optional: passed as --max-context-tokens when > 0
	providers        []core.ProviderConfig
	activeIdx        int // -1 = no provider set
	sessionEnv       []string
	routerURL        string // Claude Code Router URL (e.g., "http://127.0.0.1:3456")
	routerAPIKey     string // Claude Code Router API key (optional)
	systemPrompt     string // Custom system prompt to pass to Claude CLI
	pluginDirs       []string // Plugin directories to load via --plugin-dir (repeatable)

	appendSystemPrompt string // Custom text appended to the system prompt (keeps Claude's default)

	providerProxy  *core.ProviderProxy // local proxy for third-party providers
	proxyLocalURL  string              // local URL of the proxy
	platformPrompt string              // platform-specific formatting instructions

	// ccDataDir is injected by the cc-connect host (see buildAgentOptions
	// in cmd/cc-connect/main.go). It locates the global directory where
	// we write the shared cc-connect system prompt file (issue #1376
	// workaround for Windows 8192-byte cmdline limit). The file at
	// <ccDataDir>/agent-prompts/cc-connect-system.md is written once per
	// startup and shared across all sessions that don't need per-spawn
	// customisation. Empty value falls back to os.TempDir.
	ccDataDir string

	// spawnOpts controls OS-user isolation via run_as_user. Zero value
	// means legacy spawn as the supervisor user. See core/runas.go.
	spawnOpts core.SpawnOptions

	mu sync.RWMutex
}

var claudeProviderManagedEnvVars = map[string]struct{}{
	"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST":                  {},
	"CLAUDE_CODE_USE_BEDROCK":                               {},
	"CLAUDE_CODE_USE_VERTEX":                                {},
	"CLAUDE_CODE_USE_FOUNDRY":                               {},
	"ANTHROPIC_BASE_URL":                                    {},
	"ANTHROPIC_BEDROCK_BASE_URL":                            {},
	"ANTHROPIC_VERTEX_BASE_URL":                             {},
	"ANTHROPIC_FOUNDRY_BASE_URL":                            {},
	"ANTHROPIC_FOUNDRY_RESOURCE":                            {},
	"ANTHROPIC_VERTEX_PROJECT_ID":                           {},
	"CLOUD_ML_REGION":                                       {},
	"ANTHROPIC_API_KEY":                                     {},
	"ANTHROPIC_AUTH_TOKEN":                                  {},
	"CLAUDE_CODE_OAUTH_TOKEN":                               {},
	"AWS_BEARER_TOKEN_BEDROCK":                              {},
	"ANTHROPIC_FOUNDRY_API_KEY":                             {},
	"CLAUDE_CODE_SKIP_BEDROCK_AUTH":                         {},
	"CLAUDE_CODE_SKIP_VERTEX_AUTH":                          {},
	"CLAUDE_CODE_SKIP_FOUNDRY_AUTH":                         {},
	"ANTHROPIC_MODEL":                                       {},
	"ANTHROPIC_DEFAULT_HAIKU_MODEL":                         {},
	"ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION":             {},
	"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME":                    {},
	"ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES":  {},
	"ANTHROPIC_DEFAULT_OPUS_MODEL":                          {},
	"ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION":              {},
	"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME":                     {},
	"ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES":   {},

	// Provider-specific base URL env vars for thinking rewrite proxy routing.
	// These are set by cc-connect when thinking override is needed for
	// Bedrock/Vertex/Foundry providers that don't use base_url config.
	"ANTHROPIC_BEDROCK_PROXY_BASE_URL": {},
	"ANTHROPIC_VERTEX_PROXY_BASE_URL":  {},
	"ANTHROPIC_FOUNDRY_PROXY_BASE_URL": {},
	"ANTHROPIC_DEFAULT_SONNET_MODEL":                        {},
	"ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION":            {},
	"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME":                   {},
	"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": {},
	"ANTHROPIC_SMALL_FAST_MODEL":                            {},
	"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION":                 {},
	"CLAUDE_CODE_SUBAGENT_MODEL":                            {},
}

var claudeProviderManagedEnvPrefixes = []string{
	"VERTEX_REGION_CLAUDE_",
}

func New(opts map[string]any) (core.Agent, error) {
	workDir, _ := opts["work_dir"].(string)
	if workDir == "" {
		workDir = "."
	}
	cmd, cliExtraArgs := core.ParseCmdOpts(opts, "claude")
	cmdArgsFlag, _ := opts["cmd_args_flag"].(string)
	if cmdArgsFlag == "" {
		if v, ok := opts["cli_args_flag"].(string); ok && v != "" {
			slog.Warn("DEPRECATED: 'cli_args_flag' is deprecated, use 'cmd_args_flag' instead",
				"deprecated_key", "cli_args_flag",
				"new_key", "cmd_args_flag",
				"value", v)
			cmdArgsFlag = v
		}
	}
	model, _ := opts["model"].(string)
	reasoningEffort, _ := opts["reasoning_effort"].(string)
	mode, _ := opts["mode"].(string)
	mode = normalizePermissionMode(mode)
	systemPrompt, _ := opts["system_prompt"].(string)
	appendSystemPrompt, _ := opts["append_system_prompt"].(string)
	ccDataDir, _ := opts["cc_data_dir"].(string)

	var pluginDirs []string
	if dir, ok := opts["plugin_dir"].(string); ok && dir != "" {
		pluginDirs = []string{dir}
	} else if dirs, ok := opts["plugin_dir"].([]any); ok {
		for _, d := range dirs {
			if s, ok := d.(string); ok && s != "" {
				pluginDirs = append(pluginDirs, s)
			}
		}
	}

	var allowedTools []string
	if tools, ok := opts["allowed_tools"].([]any); ok {
		for _, t := range tools {
			if s, ok := t.(string); ok {
				allowedTools = append(allowedTools, s)
			}
		}
	}

	var disallowedTools []string
	if tools, ok := opts["disallowed_tools"].([]any); ok {
		for _, t := range tools {
			if s, ok := t.(string); ok {
				disallowedTools = append(disallowedTools, s)
			}
		}
	}

	maxContextTokens := 0
	switch v := opts["max_context_tokens"].(type) {
	case int:
		if v > 0 {
			maxContextTokens = v
		}
	case int64:
		if v > 0 {
			maxContextTokens = int(v)
		}
	case float64:
		if v > 0 {
			maxContextTokens = int(v)
		}
	}

	// Claude Code Router support
	routerURL, _ := opts["router_url"].(string)
	routerAPIKey, _ := opts["router_api_key"].(string)

	// run_as_user: optional OS-user isolation. Injected into opts from
	// the project-level config field by cmd/cc-connect/main.go.
	spawnOpts := core.SpawnOptions{}
	spawnOpts.RunAsUser, _ = opts["run_as_user"].(string)
	if env, ok := opts["run_as_env"].([]any); ok {
		for _, v := range env {
			if s, ok := v.(string); ok {
				spawnOpts.EnvAllowlist = append(spawnOpts.EnvAllowlist, s)
			}
		}
	} else if env, ok := opts["run_as_env"].([]string); ok {
		spawnOpts.EnvAllowlist = append(spawnOpts.EnvAllowlist, env...)
	}

	// When run_as_user is set, the target user's PATH is what matters;
	// skip the supervisor-side LookPath check and let spawn fail loudly
	// at runtime if the target doesn't have claude installed.
	if !spawnOpts.IsolationMode() {
		if _, err := exec.LookPath(cmd); err != nil {
			return nil, fmt.Errorf("claudecode: %q CLI not found in PATH, please install it first", cmd)
		}
	}

	// Parse project-level env from opts["env"] (set via [projects.agent.options.env] in config.toml).
	// Stored separately from runtime sessionEnv so SetSessionEnv calls cannot overwrite it.
	var configEnv []string
	if envMap, ok := opts["env"].(map[string]string); ok {
		for k, v := range envMap {
			configEnv = append(configEnv, k+"="+v)
		}
	} else if envMap, ok := opts["env"].(map[string]any); ok {
		for k, v := range envMap {
			if s, ok := v.(string); ok {
				configEnv = append(configEnv, k+"="+s)
			}
		}
	}

	// Eagerly materialise the shared cc-connect-system.md at startup so
	// the file exists on disk before the first spawn. claude reads it
	// via --append-system-prompt-file; the lazy fallback in
	// newClaudeSession still covers content drift (cc-connect upgrades)
	// and the empty-ccDataDir corner case. Failure here is non-fatal —
	// the next spawn will retry and surface the error then.
	if _, err := ensureSharedSystemPromptFile(ccDataDir, core.AgentSystemPrompt()); err != nil {
		slog.Warn("claudecode: failed to write shared system prompt file at startup; will retry on first spawn", "err", err, "cc_data_dir", ccDataDir)
	}

	return &Agent{
		workDir:          workDir,
		cmd:              cmd,
		cliExtraArgs:     cliExtraArgs,
		cmdArgsFlag:      cmdArgsFlag,
		model:            model,
		reasoningEffort:  normalizeEffort(reasoningEffort),
		mode:             mode,
		systemPrompt:     systemPrompt,
		pluginDirs:       pluginDirs,
		allowedTools:     allowedTools,
		disallowedTools:  disallowedTools,
		maxContextTokens: maxContextTokens,
		configEnv:        configEnv,
		activeIdx:        -1,
		routerURL:        routerURL,
		routerAPIKey:     routerAPIKey,
		spawnOpts:        spawnOpts,
		ccDataDir:        ccDataDir,

		appendSystemPrompt: appendSystemPrompt,
	}, nil
}

// normalizeEffort maps user-friendly aliases to Claude CLI --effort values.
func normalizeEffort(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "":
		return ""
	case "low":
		return "low"
	case "medium", "med":
		return "medium"
	case "high":
		return "high"
	case "max":
		return "max"
	default:
		return ""
	}
}

// normalizePermissionMode maps user-friendly aliases to Claude CLI values.
func normalizePermissionMode(raw string) string {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "acceptedits", "accept-edits", "accept_edits", "edit":
		return "acceptEdits"
	case "plan":
		return "plan"
	case "auto":
		return "auto"
	case "bypasspermissions", "bypass-permissions", "bypass_permissions",
		"yolo":
		return "bypassPermissions"
	case "dontask", "dont-ask", "dont_ask":
		return "dontAsk"
	default:
		return "default"
	}
}

func (a *Agent) Name() string           { return "claudecode" }
func (a *Agent) CLIBinaryName() string  { return a.cmd }
func (a *Agent) CLIDisplayName() string { return "Claude" }

func (a *Agent) SetWorkDir(dir string) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.workDir = dir
	slog.Info("claudecode: work_dir changed", "work_dir", dir)
}

func (a *Agent) GetWorkDir() string {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.workDir
}

func (a *Agent) SetModel(model string) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.model = model
	slog.Info("claudecode: model changed", "model", model)
}

func (a *Agent) GetModel() string {
	a.mu.Lock()
	defer a.mu.Unlock()
	return core.GetProviderModel(a.providers, a.activeIdx, a.model)
}

func (a *Agent) SetReasoningEffort(effort string) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.reasoningEffort = normalizeEffort(effort)
	slog.Info("claudecode: reasoning effort changed", "effort", a.reasoningEffort)
}

func (a *Agent) GetReasoningEffort() string {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.reasoningEffort
}

func (a *Agent) AvailableReasoningEfforts() []string {
	return []string{"low", "medium", "high", "max"}
}

func (a *Agent) configuredModels() []core.ModelOption {
	a.mu.RLock()
	defer a.mu.RUnlock()
	return core.GetProviderModels(a.providers, a.activeIdx)
}

func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
	if models := a.configuredModels(); len(models) > 0 {
		return models
	}
	if models := a.fetchModelsFromAPI(ctx); len(models) > 0 {
		return models
	}
	return []core.ModelOption{
		{Name: "sonnet", Desc: "Claude Sonnet (balanced)"},
		{Name: "opus", Desc: "Claude Opus (most capable)"},
		{Name: "opus[1m]", Desc: "Claude Opus (1M context)"},
		{Name: "haiku", Desc: "Claude Haiku (fastest)"},
	}
}

func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption {
	a.mu.Lock()
	apiKey := ""
	baseURL := ""
	if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
		apiKey = a.providers[a.activeIdx].APIKey
		baseURL = a.providers[a.activeIdx].BaseURL
	}
	a.mu.Unlock()

	if apiKey == "" {
		apiKey = os.Getenv("ANTHROPIC_API_KEY")
	}
	if apiKey == "" {
		return nil
	}
	if baseURL == "" {
		baseURL = os.Getenv("ANTHROPIC_BASE_URL")
	}
	if baseURL == "" {
		baseURL = "https://api.anthropic.com"
	}
	baseURL = strings.TrimRight(baseURL, "/")

	req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/v1/models", nil)
	if err != nil {
		return nil
	}
	req.Header.Set("x-api-key", apiKey)
	req.Header.Set("anthropic-version", "2023-06-01")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		slog.Debug("claudecode: failed to fetch models", "error", err)
		return nil
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil
	}

	var result struct {
		Data []struct {
			ID          string `json:"id"`
			DisplayName string `json:"display_name"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil
	}

	var models []core.ModelOption
	for _, m := range result.Data {
		models = append(models, core.ModelOption{Name: m.ID, Desc: m.DisplayName})
	}
	return models
}

func (a *Agent) SetSessionEnv(env []string) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.sessionEnv = env
}

func (a *Agent) SetPlatformPrompt(prompt string) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.platformPrompt = prompt
}

// ValidateSessionID reports whether the given session ID exists in this
// agent's per-project session store (issue #599). It is the agent-side
// implementation of core.SessionIDValidator: when the engine is about to
// resume a session whose stored ID was inherited from another project, the
// engine calls this and — on a false return — clears the ID and starts a
// fresh session instead of reloading the wrong conversation.
func (a *Agent) ValidateSessionID(_ context.Context, sessionID string) bool {
	if sessionID == "" {
		return false
	}
	homeDir, err := os.UserHomeDir()
	if err != nil {
		return false
	}
	a.mu.RLock()
	workDir := a.workDir
	a.mu.RUnlock()
	return validateSessionIDInProject(homeDir, workDir, sessionID)
}

// validateSessionIDInProject checks whether sessionID has a .jsonl file
// under the per-project directory that Claude Code derives from workDir.
// Split out from (*Agent).ValidateSessionID so the logic can be unit
// tested without touching the real $HOME.
func validateSessionIDInProject(homeDir, workDir, sessionID string) bool {
	if sessionID == "" {
		return false
	}
	if homeDir == "" || workDir == "" {
		return false
	}
	absWorkDir, err := filepath.Abs(workDir)
	if err != nil {
		return false
	}
	projectDir := findProjectDir(homeDir, absWorkDir)
	if projectDir == "" {
		return false
	}
	path := filepath.Join(projectDir, sessionID+".jsonl")
	_, err = os.Stat(path)
	return err == nil
}

// StartSession creates a persistent interactive Claude Code session.
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
	a.mu.Lock()
	tools := make([]string, len(a.allowedTools))
	copy(tools, a.allowedTools)
	disTools := make([]string, len(a.disallowedTools))
	copy(disTools, a.disallowedTools)
	maxTok := a.maxContextTokens
	model := a.model
	effort := a.reasoningEffort
	workDir := a.workDir
	mode := a.mode
	pluginDirs := make([]string, len(a.pluginDirs))
	copy(pluginDirs, a.pluginDirs)
	extraEnv := a.runtimeEnvLocked()

	activeIdx := a.activeIdx
	var activeProviderName string
	if activeIdx >= 0 && activeIdx < len(a.providers) {
		activeProviderName = a.providers[activeIdx].Name
		if m := a.providers[activeIdx].Model; m != "" {
			model = m
		}
	}
	slog.Debug("claudecode: StartSession provider state",
		"activeIdx", activeIdx,
		"activeProvider", activeProviderName,
		"model", model,
		"sessionID", sessionID,
		"providerCount", len(a.providers))
	platformPrompt := a.platformPrompt
	systemPrompt := a.systemPrompt
	appendSystemPrompt := a.appendSystemPrompt
	// When router_url is set, --verbose conflicts with --output-format stream-json
	// (verbose emits non-JSON text to stdout that corrupts the JSON stream).
	disableVerbose := a.routerURL != ""
	a.mu.Unlock()

	return newClaudeSession(ctx, workDir, a.cmd, a.cliExtraArgs, a.cmdArgsFlag, model, effort, sessionID, mode, systemPrompt, appendSystemPrompt, tools, disTools, pluginDirs, extraEnv, platformPrompt, disableVerbose, a.spawnOpts, maxTok, a.ccDataDir)
}

func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
	homeDir, err := os.UserHomeDir()
	if err != nil {
		return nil, fmt.Errorf("claudecode: cannot determine home dir: %w", err)
	}

	a.mu.RLock()
	workDir := a.workDir
	a.mu.RUnlock()
	absWorkDir, err := filepath.Abs(workDir)
	if err != nil {
		return nil, fmt.Errorf("claudecode: resolve work_dir: %w", err)
	}

	projectDir := findProjectDir(homeDir, absWorkDir)
	if projectDir == "" {
		return nil, nil
	}

	entries, err := os.ReadDir(projectDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, fmt.Errorf("claudecode: read project dir: %w", err)
	}

	var sessions []core.AgentSessionInfo
	for _, entry := range entries {
		name := entry.Name()
		if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") {
			continue
		}

		sessionID := strings.TrimSuffix(name, ".jsonl")
		info, err := entry.Info()
		if err != nil {
			continue
		}

		summary, msgCount := scanSessionMeta(filepath.Join(projectDir, name))

		sessions = append(sessions, core.AgentSessionInfo{
			ID:           sessionID,
			Summary:      summary,
			MessageCount: msgCount,
			ModifiedAt:   info.ModTime(),
		})
	}

	sort.Slice(sessions, func(i, j int) bool {
		return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt)
	})

	return sessions, nil
}

func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
	homeDir, err := os.UserHomeDir()
	if err != nil {
		return fmt.Errorf("claudecode: cannot determine home dir: %w", err)
	}
	a.mu.RLock()
	workDir := a.workDir
	a.mu.RUnlock()
	absWorkDir, err := filepath.Abs(workDir)
	if err != nil {
		return fmt.Errorf("claudecode: resolve work_dir: %w", err)
	}
	projectDir := findProjectDir(homeDir, absWorkDir)
	if projectDir == "" {
		return fmt.Errorf("session not found")
	}
	path := filepath.Join(projectDir, sessionID+".jsonl")
	if _, err := os.Stat(path); os.IsNotExist(err) {
		return fmt.Errorf("session file not found: %s", sessionID)
	}
	return os.Remove(path)
}

// extractStringContent attempts to extract a plain string from a json.RawMessage.
// Returns empty string if the raw message is not a JSON string.
func extractStringContent(raw json.RawMessage) string {
	if len(raw) == 0 {
		return ""
	}
	var s string
	if err := json.Unmarshal(raw, &s); err != nil {
		return ""
	}
	return s
}

func scanSessionMeta(path string) (string, int) {
	f, err := os.Open(path)
	if err != nil {
		return "", 0
	}
	defer f.Close()

	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 256*1024), 256*1024)

	var summary string
	var count int

	for scanner.Scan() {
		var entry struct {
			Type    string `json:"type"`
			Message struct {
				Content json.RawMessage `json:"content"`
			} `json:"message"`
		}
		if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
			continue
		}
		if entry.Type == "user" || entry.Type == "assistant" {
			count++
			if entry.Type == "user" {
				if s := extractStringContent(entry.Message.Content); s != "" {
					summary = s
				}
			}
		}
	}
	summary = stripXMLTags(summary)
	summary = strings.TrimSpace(summary)
	if utf8.RuneCountInString(summary) > 40 {
		summary = string([]rune(summary)[:40]) + "..."
	}
	return summary, count
}

var xmlTagRe = regexp.MustCompile(`<[^>]+>`)

func stripXMLTags(s string) string {
	return xmlTagRe.ReplaceAllString(s, "")
}

// GetSessionHistory reads the Claude Code JSONL transcript and returns user/assistant messages.
func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) {
	homeDir, err := os.UserHomeDir()
	if err != nil {
		return nil, err
	}
	a.mu.RLock()
	workDir := a.workDir
	a.mu.RUnlock()
	absWorkDir, _ := filepath.Abs(workDir)
	projectDir := findProjectDir(homeDir, absWorkDir)
	if projectDir == "" {
		return nil, fmt.Errorf("claudecode: project dir not found")
	}

	path := filepath.Join(projectDir, sessionID+".jsonl")
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("claudecode: open session file: %w", err)
	}
	defer f.Close()

	var entries []core.HistoryEntry
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 256*1024), 256*1024)

	for scanner.Scan() {
		var raw struct {
			Type      string `json:"type"`
			Timestamp string `json:"timestamp"`
			Message   struct {
				Role    string          `json:"role"`
				Content json.RawMessage `json:"content"`
			} `json:"message"`
		}
		if json.Unmarshal(scanner.Bytes(), &raw) != nil {
			continue
		}
		if raw.Type != "user" && raw.Type != "assistant" {
			continue
		}

		ts, _ := time.Parse(time.RFC3339Nano, raw.Timestamp)
		text := extractTextContent(raw.Message.Content)
		if text == "" {
			continue
		}

		entries = append(entries, core.HistoryEntry{
			Role:      raw.Type,
			Content:   text,
			Timestamp: ts,
		})
	}

	if limit > 0 && len(entries) > limit {
		entries = entries[len(entries)-limit:]
	}
	return entries, nil
}

// extractTextContent extracts readable text from Claude Code message content.
// Content can be a plain string or an array of content blocks.
func extractTextContent(raw json.RawMessage) string {
	if len(raw) == 0 {
		return ""
	}

	// Try plain string first
	var s string
	if json.Unmarshal(raw, &s) == nil {
		return s
	}

	// Try array of content blocks
	var blocks []struct {
		Type     string `json:"type"`
		Text     string `json:"text"`
		Thinking string `json:"thinking"`
	}
	if json.Unmarshal(raw, &blocks) != nil {
		return ""
	}

	for _, b := range blocks {
		if b.Type == "text" && b.Text != "" {
			return b.Text
		}
	}
	return ""
}

func (a *Agent) Stop() error { return nil }

// SetMode changes the permission mode for future sessions.
func (a *Agent) SetMode(mode string) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.mode = normalizePermissionMode(mode)
	slog.Info("claudecode: permission mode changed", "mode", a.mode)
}

// GetMode returns the current permission mode.
func (a *Agent) GetMode() string {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.mode
}

// GetRunAsUser returns the target user for OS-isolation spawning, or ""
// if no isolation is configured. Set at construction from the project-level
// run_as_user field (injected into opts by cmd/cc-connect/main.go).
//
// This accessor exists specifically so multi-workspace mode can propagate
// run_as_user from the parent (project-level) agent into per-workspace
// agent instances created lazily by core.Engine.getOrCreateWorkspaceAgent.
// Without this, workspace agents are constructed with a fresh opts map
// that never contained run_as_user, silently dropping back to the legacy
// supervisor-user spawn path — which is exactly the leak cc-connect#496
// is designed to prevent.
func (a *Agent) GetRunAsUser() string {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.spawnOpts.RunAsUser
}

// GetRunAsEnv returns the user-configured env allowlist extension (the
// run_as_env project field), which is merged with core.DefaultEnvAllowlist
// at spawn time. Returns nil if no extension is configured.
//
// Used by the multi-workspace propagation path alongside GetRunAsUser.
func (a *Agent) GetRunAsEnv() []string {
	a.mu.Lock()
	defer a.mu.Unlock()
	if len(a.spawnOpts.EnvAllowlist) == 0 {
		return nil
	}
	out := make([]string, len(a.spawnOpts.EnvAllowlist))
	copy(out, a.spawnOpts.EnvAllowlist)
	return out
}

// WorkspaceAgentOptions returns a snapshot of user-configured options that
// must propagate to per-workspace agent instances created lazily by
// core.Engine.getOrCreateWorkspaceAgent. Without this snapshot, the engine
// constructs workspace agents from a fresh opts map and silently drops
// every claudecode field except mode/model — so cmd, allowed_tools,
// and friends would only take effect on the project-level agent.
//
// Runtime-only state (providers, sessionEnv, providerProxy, platformPrompt)
// is intentionally omitted: providers are rewired separately by the engine
// after construction; the rest is per-session and recomputed.
//
// configEnv IS included because it comes from the static config file and must
// propagate to every workspace agent. sessionEnv is excluded (runtime-only).
//
// run_as_user / run_as_env are also omitted because the engine has its own
// dedicated propagation path via GetRunAsUser/GetRunAsEnv (see cc-connect#496).
func (a *Agent) WorkspaceAgentOptions() map[string]any {
	a.mu.RLock()
	defer a.mu.RUnlock()

	opts := map[string]any{
		"mode": a.mode,
	}
	if len(a.configEnv) > 0 {
		envMap := make(map[string]string, len(a.configEnv))
		for _, kv := range a.configEnv {
			k, v, _ := strings.Cut(kv, "=")
			envMap[k] = v
		}
		opts["env"] = envMap
	}
	if cliPath := snapshotCmdPath(a.cmd, a.cliExtraArgs); cliPath != "" {
		opts["cmd"] = cliPath
	}
	if a.cmdArgsFlag != "" {
		opts["cmd_args_flag"] = a.cmdArgsFlag
	}
	if a.model != "" {
		opts["model"] = a.model
	}
	if a.reasoningEffort != "" {
		opts["reasoning_effort"] = a.reasoningEffort
	}
	if len(a.allowedTools) > 0 {
		opts["allowed_tools"] = stringsToAny(a.allowedTools)
	}
	if len(a.disallowedTools) > 0 {
		opts["disallowed_tools"] = stringsToAny(a.disallowedTools)
	}
	if a.maxContextTokens > 0 {
		opts["max_context_tokens"] = a.maxContextTokens
	}
	if a.routerURL != "" {
		opts["router_url"] = a.routerURL
	}
	if a.routerAPIKey != "" {
		opts["router_api_key"] = a.routerAPIKey
	}
	if len(a.pluginDirs) > 0 {
		opts["plugin_dir"] = stringsToAny(a.pluginDirs)
	}
	return opts
}

// snapshotCmdPath rebuilds the cmd opts string from cmd and the
// extra-args tail captured at construction. Returns "" when only the
// default "claude" binary is in use, so we don't pollute the workspace
// opts with a redundant default.
func snapshotCmdPath(cmd string, cliExtraArgs []string) string {
	// Normalise empty to the default binary so we can reason about extra args.
	if cmd == "" {
		cmd = "claude"
	}
	if cmd == "claude" && len(cliExtraArgs) == 0 {
		return "" // default binary, no extra args — no need to persist
	}
	if len(cliExtraArgs) == 0 {
		return cmd
	}
	return cmd + " " + strings.Join(cliExtraArgs, " ")
}

// stringsToAny copies a []string into a fresh []any so it round-trips
// through New()'s opts["..."].([]any) type assertion.
func stringsToAny(in []string) []any {
	out := make([]any, len(in))
	for i, s := range in {
		out[i] = s
	}
	return out
}

// PermissionModes returns all supported permission modes.
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
	return []core.PermissionModeInfo{
		{Key: "default", Name: "Default", NameZh: "默认", Desc: "Ask permission for every tool call", DescZh: "每次工具调用都需确认"},
		{Key: "acceptEdits", Name: "Accept Edits", NameZh: "接受编辑", Desc: "Auto-approve file edits, ask for others", DescZh: "自动允许文件编辑，其他需确认"},
		{Key: "plan", Name: "Plan Mode", NameZh: "计划模式", Desc: "Plan only, no execution until approved", DescZh: "只做规划不执行，审批后再执行"},
		{Key: "auto", Name: "Auto", NameZh: "自动模式", Desc: "Claude decides when to ask for permission", DescZh: "由 Claude 自动判断何时需要确认"},
		{Key: "bypassPermissions", Name: "YOLO", NameZh: "YOLO 模式", Desc: "Auto-approve everything", DescZh: "全部自动通过"},
		{Key: "dontAsk", Name: "Don't Ask", NameZh: "静默拒绝", Desc: "Auto-deny tools unless pre-approved via allowed_tools or settings.json allow rules", DescZh: "未预授权的工具自动拒绝，不弹确认"},
	}
}

// AddAllowedTools adds tools to the pre-allowed list (takes effect on next session).
func (a *Agent) AddAllowedTools(tools ...string) error {
	a.mu.Lock()
	defer a.mu.Unlock()

	existing := make(map[string]bool)
	for _, t := range a.allowedTools {
		existing[t] = true
	}
	for _, tool := range tools {
		if !existing[tool] {
			a.allowedTools = append(a.allowedTools, tool)
			existing[tool] = true
		}
	}
	slog.Info("claudecode: updated allowed tools", "tools", tools, "total", len(a.allowedTools))
	return nil
}

// GetAllowedTools returns the current list of pre-allowed tools.
func (a *Agent) GetAllowedTools() []string {
	a.mu.Lock()
	defer a.mu.Unlock()
	result := make([]string, len(a.allowedTools))
	copy(result, a.allowedTools)
	return result
}

// GetDisallowedTools returns the current list of disallowed tools.
func (a *Agent) GetDisallowedTools() []string {
	a.mu.Lock()
	defer a.mu.Unlock()
	result := make([]string, len(a.disallowedTools))
	copy(result, a.disallowedTools)
	return result
}

// ── CommandProvider implementation ────────────────────────────

func (a *Agent) CommandDirs() []string {
	a.mu.RLock()
	workDir := a.workDir
	a.mu.RUnlock()
	absDir, err := filepath.Abs(workDir)
	if err != nil {
		absDir = workDir
	}
	dirs := []string{filepath.Join(absDir, ".claude", "commands")}
	if home, err := os.UserHomeDir(); err == nil {
		dirs = append(dirs, filepath.Join(home, ".claude", "commands"))
	}
	return dirs
}

// ── SkillProvider implementation ──────────────────────────────

func (a *Agent) SkillDirs() []string {
	a.mu.RLock()
	workDir := a.workDir
	a.mu.RUnlock()
	absDir, err := filepath.Abs(workDir)
	if err != nil {
		absDir = workDir
	}
	return appendProjectClaudeSkillDirs(absDir, claudeConfigHomeDir())
}

// ── ContextCompressor implementation ──────────────────────────

func (a *Agent) CompressCommand() string { return "/compact" }

func claudeConfigHomeDir() string {
	if dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR")); dir != "" {
		return dir
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return ""
	}
	return filepath.Join(home, ".claude")
}

func appendProjectClaudeSkillDirs(workDir, configHome string) []string {
	home, _ := os.UserHomeDir()
	projectDirs := walkUpClaudeSkillDirs(workDir, home)
	if configHome == "" {
		return projectDirs
	}
	return uniqueSkillDirs(append(projectDirs, filepath.Join(configHome, "skills")))
}

func walkUpClaudeSkillDirs(workDir, home string) []string {
	current := filepath.Clean(workDir)
	home = filepath.Clean(home)
	stopAt := findGitRoot(current)

	var dirs []string
	for {
		if home != "" && samePath(current, home) {
			break
		}
		dirs = append(dirs, filepath.Join(current, ".claude", "skills"))
		if stopAt != "" && samePath(current, stopAt) {
			break
		}
		parent := filepath.Dir(current)
		if parent == current {
			break
		}
		current = parent
	}
	return uniqueSkillDirs(dirs)
}

func findGitRoot(start string) string {
	current := filepath.Clean(start)
	for {
		gitPath := filepath.Join(current, ".git")
		if _, err := os.Stat(gitPath); err == nil {
			return current
		}
		parent := filepath.Dir(current)
		if parent == current {
			return ""
		}
		current = parent
	}
}

func samePath(a, b string) bool {
	if a == "" || b == "" {
		return false
	}
	return filepath.Clean(a) == filepath.Clean(b)
}

func uniqueSkillDirs(paths []string) []string {
	seen := make(map[string]struct{}, len(paths))
	out := make([]string, 0, len(paths))
	for _, path := range paths {
		if path == "" {
			continue
		}
		clean := filepath.Clean(path)
		if _, ok := seen[clean]; ok {
			continue
		}
		seen[clean] = struct{}{}
		out = append(out, clean)
	}
	return out
}

// ── MemoryFileProvider implementation ─────────────────────────

func (a *Agent) ProjectMemoryFile() string {
	a.mu.RLock()
	workDir := a.workDir
	a.mu.RUnlock()
	absDir, err := filepath.Abs(workDir)
	if err != nil {
		absDir = workDir
	}
	return filepath.Join(absDir, "CLAUDE.md")
}

func (a *Agent) GlobalMemoryFile() string {
	homeDir, err := os.UserHomeDir()
	if err != nil {
		return ""
	}
	return filepath.Join(homeDir, ".claude", "CLAUDE.md")
}

func (a *Agent) HasSystemPromptSupport() bool { return true }

// ── ProviderSwitcher implementation ──────────────────────────

func (a *Agent) SetProviders(providers []core.ProviderConfig) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.providers = providers
}

func (a *Agent) SetActiveProvider(name string) bool {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.stopProviderProxyLocked()
	if name == "" {
		a.activeIdx = -1
		slog.Info("claudecode: provider cleared")
		return true
	}
	for i, p := range a.providers {
		if p.Name == name {
			a.activeIdx = i
			slog.Info("claudecode: provider switched", "provider", name)
			return true
		}
	}
	return false
}

func (a *Agent) GetActiveProvider() *core.ProviderConfig {
	a.mu.Lock()
	defer a.mu.Unlock()
	if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
		return nil
	}
	p := a.providers[a.activeIdx]
	return &p
}

func (a *Agent) ListProviders() []core.ProviderConfig {
	a.mu.Lock()
	defer a.mu.Unlock()
	result := make([]core.ProviderConfig, len(a.providers))
	copy(result, a.providers)
	return result
}

// providerEnvLocked returns env vars for the active provider. Caller must hold mu.
//
// When a custom base_url is configured:
//  1. We use ANTHROPIC_AUTH_TOKEN (Bearer) instead of ANTHROPIC_API_KEY
//     (x-api-key). Claude Code validates API keys against api.anthropic.com
//     which hangs for third-party endpoints; Bearer auth skips that check.
//  2. If the provider sets thinking (e.g. "disabled"), a local reverse proxy
//     rewrites the thinking parameter for compatibility with providers that
//     don't support adaptive thinking.
//
// For env-only providers (Bedrock, Vertex, Foundry) that don't set base_url
// but use CLAUDE_CODE_USE_BEDROCK/VERTEX/FOUNDRY env vars, the thinking
// rewrite proxy routes via ANTHROPIC_*_BASE_URL override env vars.
func (a *Agent) providerEnvLocked() []string {
	if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
		a.stopProviderProxyLocked()
		return nil
	}
	p := a.providers[a.activeIdx]
	var env []string

	if p.BaseURL != "" {
		if p.Thinking != "" {
			if err := a.ensureProviderProxyLocked(p.BaseURL, p.Thinking); err != nil {
				slog.Error("providerproxy: failed to start", "error", err)
				env = append(env, "ANTHROPIC_BASE_URL="+p.BaseURL)
			} else {
				env = append(env, "ANTHROPIC_BASE_URL="+a.proxyLocalURL)
				env = append(env, "NO_PROXY=127.0.0.1")
			}
		} else {
			a.stopProviderProxyLocked()
			env = append(env, "ANTHROPIC_BASE_URL="+p.BaseURL)
		}
		if p.APIKey != "" {
			env = append(env, "ANTHROPIC_AUTH_TOKEN="+p.APIKey)
			env = append(env, "ANTHROPIC_API_KEY=")
		}
		if p.Model != "" {
			env = append(env, "ANTHROPIC_MODEL="+p.Model)
		}
	} else {
		// Check for env-only providers (Bedrock, Vertex, Foundry) that need thinking rewrite.
		if p.Thinking != "" {
			providerType := detectEnvOnlyProviderType(p.Env)
			if providerType != "" {
				targetURL := getDefaultEndpointForProviderType(providerType)
				if targetURL != "" {
					if err := a.ensureProviderProxyLocked(targetURL, p.Thinking); err != nil {
						slog.Error("providerproxy: failed to start for "+providerType, "error", err)
						a.stopProviderProxyLocked()
					} else {
						// Route the provider-specific requests through our proxy.
						baseURLEnvVar := getBaseURLEnvVarForProviderType(providerType)
						env = append(env, baseURLEnvVar+"="+a.proxyLocalURL)
						env = append(env, "NO_PROXY=127.0.0.1")
						slog.Info("claudecode: thinking rewrite proxy enabled for "+providerType,
							"target", targetURL, "local", a.proxyLocalURL, "thinking", p.Thinking)
					}
				} else {
					a.stopProviderProxyLocked()
				}
			} else {
				a.stopProviderProxyLocked()
			}
		} else {
			a.stopProviderProxyLocked()
		}
		if p.APIKey != "" {
			env = append(env, "ANTHROPIC_API_KEY="+p.APIKey)
		}
	}

	for k, v := range p.Env {
		env = append(env, k+"="+v)
	}
	slog.Debug("claudecode: providerEnv",
		"provider", p.Name,
		"model", p.Model,
		"env", core.RedactEnv(env))
	return env
}

func (a *Agent) runtimeEnvLocked() []string {
	// configEnv (from config.toml [env]) is lower priority than provider keys or
	// session-injected vars, but must survive SetSessionEnv calls (which only
	// overwrite sessionEnv). Prepend it so later entries win on conflict.
	env := append([]string(nil), a.configEnv...)
	env = append(env, a.providerEnvLocked()...)
	env = append(env, a.sessionEnv...)

	if a.routerURL != "" {
		env = append(env, "ANTHROPIC_BASE_URL="+a.routerURL)
		env = append(env, "NO_PROXY=127.0.0.1")
		env = append(env, "DISABLE_TELEMETRY=true")
		env = append(env, "DISABLE_COST_WARNINGS=true")
	}
	if a.routerAPIKey != "" {
		env = append(env, "ANTHROPIC_API_KEY="+a.routerAPIKey)
	}

	if !claudeEnvManagesProviderRouting(env) {
		return env
	}
	return core.MergeEnv(env, []string{"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1"})
}

func claudeEnvManagesProviderRouting(env []string) bool {
	for _, entry := range env {
		key, _, ok := strings.Cut(entry, "=")
		if !ok {
			continue
		}
		upper := strings.ToUpper(strings.TrimSpace(key))
		if _, ok := claudeProviderManagedEnvVars[upper]; ok {
			return true
		}
		for _, prefix := range claudeProviderManagedEnvPrefixes {
			if strings.HasPrefix(upper, prefix) {
				return true
			}
		}
	}
	return false
}

func (a *Agent) ensureProviderProxyLocked(targetURL, thinkingOverride string) error {
	if a.providerProxy != nil && a.proxyLocalURL != "" {
		return nil
	}
	a.stopProviderProxyLocked()
	proxy, localURL, err := core.NewProviderProxy(targetURL, thinkingOverride)
	if err != nil {
		return err
	}
	a.providerProxy = proxy
	a.proxyLocalURL = localURL
	return nil
}

func (a *Agent) stopProviderProxyLocked() {
	if a.providerProxy != nil {
		a.providerProxy.Close()
		a.providerProxy = nil
		a.proxyLocalURL = ""
	}
}

// detectEnvOnlyProviderType checks if the provider uses Bedrock, Vertex, or Foundry
// via environment variables (without base_url). Returns "bedrock", "vertex", "foundry",
// or empty string if not detected.
func detectEnvOnlyProviderType(env map[string]string) string {
	if env == nil {
		return ""
	}
	if env["CLAUDE_CODE_USE_BEDROCK"] == "1" {
		return "bedrock"
	}
	if env["CLAUDE_CODE_USE_VERTEX"] == "1" {
		return "vertex"
	}
	if env["CLAUDE_CODE_USE_FOUNDRY"] == "1" {
		return "foundry"
	}
	return ""
}

// getDefaultEndpointForProviderType returns the default API endpoint for Bedrock/Vertex/Foundry.
// Used as the proxy target when thinking rewrite is needed for env-only providers.
func getDefaultEndpointForProviderType(providerType string) string {
	switch providerType {
	case "bedrock":
		// Bedrock cross-region inference endpoint; works with AWS SDK auth.
		// User can override region via AWS_REGION or CLOUD_ML_REGION env var.
		return "https://bedrock-runtime.us-east-1.amazonaws.com"
	case "vertex":
		// Vertex AI endpoint; requires CLOUD_ML_REGION env var for region.
		return "https://us-east1-aiplatform.googleapis.com"
	case "foundry":
		// Anthropic Foundry internal endpoint (rarely used externally).
		return "https://api.anthropic.com"
	default:
		return ""
	}
}

// getBaseURLEnvVarForProviderType returns the environment variable name that
// Claude Code uses to override the base URL for Bedrock/Vertex/Foundry providers.
func getBaseURLEnvVarForProviderType(providerType string) string {
	switch providerType {
	case "bedrock":
		return "ANTHROPIC_BEDROCK_BASE_URL"
	case "vertex":
		return "ANTHROPIC_VERTEX_BASE_URL"
	case "foundry":
		return "ANTHROPIC_FOUNDRY_BASE_URL"
	default:
		return ""
	}
}

// summarizeInput produces a short human-readable description of tool input.
func summarizeInput(tool string, input any) string {
	m, ok := input.(map[string]any)
	if !ok {
		return ""
	}

	switch tool {
	case "Read", "Edit", "Write":
		if fp, ok := m["file_path"].(string); ok {
			return fp
		}
	case "Bash":
		if cmd, ok := m["command"].(string); ok {
			return cmd
		}
	case "Grep":
		if p, ok := m["pattern"].(string); ok {
			return p
		}
	case "Glob":
		if p, ok := m["pattern"].(string); ok {
			return p
		}
		if p, ok := m["glob_pattern"].(string); ok {
			return p
		}
	}

	b, err := json.Marshal(m)
	if err != nil {
		return ""
	}
	return string(b)
}

// parseUserQuestions extracts structured questions from AskUserQuestion input.
func parseUserQuestions(input map[string]any) []core.UserQuestion {
	questionsRaw, ok := input["questions"].([]any)
	if !ok || len(questionsRaw) == 0 {
		return nil
	}
	var questions []core.UserQuestion
	for _, qRaw := range questionsRaw {
		qMap, ok := qRaw.(map[string]any)
		if !ok {
			continue
		}
		q := core.UserQuestion{
			Question:    strVal(qMap, "question"),
			Header:      strVal(qMap, "header"),
			MultiSelect: boolVal(qMap, "multiSelect"),
		}
		if optsRaw, ok := qMap["options"].([]any); ok {
			for _, oRaw := range optsRaw {
				oMap, ok := oRaw.(map[string]any)
				if !ok {
					continue
				}
				q.Options = append(q.Options, core.UserQuestionOption{
					Label:       strVal(oMap, "label"),
					Description: strVal(oMap, "description"),
				})
			}
		}
		if q.Question != "" {
			questions = append(questions, q)
		}
	}
	return questions
}

func strVal(m map[string]any, key string) string {
	v, _ := m[key].(string)
	return v
}

func boolVal(m map[string]any, key string) bool {
	v, _ := m[key].(bool)
	return v
}

// encodeClaudeProjectKey converts an absolute path to Claude Code's project key format.
// Claude Code encodes paths by:
//  1. Replacing path separators (/ or \) with "-"
//  2. Replacing colons (:) with "-" (Windows drive letters)
//  3. Replacing underscores (_) with "-"
//  4. Replacing spaces and tildes (~) with "-" (common in macOS iCloud paths like
//     "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/...")
//  5. Replacing all non-ASCII characters with "-"
func encodeClaudeProjectKey(absPath string) string {
	// First, normalize to forward slashes for consistent processing
	normalized := strings.ReplaceAll(absPath, "\\", "/")

	// Build the encoded key character by character.
	// Claude Code replaces path separators, special punctuation, and non-ASCII
	// characters with hyphens when deriving the project directory name.
	var result strings.Builder
	for _, r := range normalized {
		if r == '/' || r == ':' || r == '_' || r == ' ' || r == '~' || r == '.' || r == '@' {
			result.WriteRune('-')
		} else if r < 128 { // ASCII range (0-127)
			result.WriteRune(r)
		} else {
			// Non-ASCII characters become hyphens
			result.WriteRune('-')
		}
	}
	return result.String()
}

// findProjectDir locates the Claude Code session directory for a given work dir.
// Claude Code stores sessions at ~/.claude/projects/{projectKey}/ where projectKey
// is derived from the absolute path. On Windows, the key format may vary (colon
// handling, slash direction), so we try multiple key candidates and fall back to
// scanning the projects directory.
func findProjectDir(homeDir, absWorkDir string) string {
	projectsBase := filepath.Join(homeDir, ".claude", "projects")

	// Build candidate keys: different ways Claude Code might encode the path.
	// Primary encoding: Claude Code's actual algorithm (non-ASCII → "-")
	candidates := []string{
		encodeClaudeProjectKey(absWorkDir),
		// Legacy candidates for backward compatibility
		strings.ReplaceAll(absWorkDir, string(filepath.Separator), "-"),
		strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace(absWorkDir),
		strings.NewReplacer("/", "-", "\\", "-", ":", "-", "_", "-").Replace(absWorkDir),
	}
	// Also try with forward slashes (config might use forward slashes on Windows)
	fwd := strings.ReplaceAll(absWorkDir, "\\", "/")
	candidates = append(candidates, strings.ReplaceAll(fwd, "/", "-"))

	for _, key := range candidates {
		dir := filepath.Join(projectsBase, key)
		if _, err := os.Stat(dir); err == nil {
			return dir
		}
	}

	// Fallback: scan the projects directory and find a match by
	// comparing the encoded path (handles variations in encoding).
	entries, err := os.ReadDir(projectsBase)
	if err != nil {
		return ""
	}

	// Use the primary encoding for comparison
	encodedWorkDir := encodeClaudeProjectKey(absWorkDir)
	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		// Direct match with encoded key
		if entry.Name() == encodedWorkDir {
			return filepath.Join(projectsBase, entry.Name())
		}
		// Case-insensitive match for Windows compatibility
		if strings.EqualFold(entry.Name(), encodedWorkDir) {
			return filepath.Join(projectsBase, entry.Name())
		}
	}

	return ""
}
