package agent

import (
	"context"
	"fmt"
	"io"
	"log/slog"
	"os"
	"os/exec"
	"runtime"
	"strings"

	"github.com/google/uuid"

	"github.com/tidbcloud/AgentTeam/desktop/internal/agent/drivercontract"
	"github.com/tidbcloud/AgentTeam/desktop/internal/agent/runtimeconfig"
	"github.com/tidbcloud/AgentTeam/desktop/internal/types"
	"github.com/tidbcloud/AgentTeam/internal/core/rewindcaps"
)

// Spawn launches the Claude CLI and writes the initial prompt to stdin.
func (d *ClaudeDriver) Spawn(ctx context.Context, cfg drivercontract.LaunchConfig) (drivercontract.SpawnResult, error) {
	carrier, err := cfg.RequireCompiledPrompt()
	if err != nil {
		return drivercontract.SpawnResult{}, err
	}
	cfg.CompiledPrompt = carrier
	appendSystemPrompt, err := buildClaudeAppendSystemPrompt(cfg)
	if err != nil {
		return drivercontract.SpawnResult{}, err
	}

	permissionMode, err := cfg.EffectivePermissionMode()
	if err != nil {
		return drivercontract.SpawnResult{}, err
	}
	if permissionMode == types.AgentPermissionModeWorkspaceWrite {
		return drivercontract.SpawnResult{}, fmt.Errorf("claude runtime does not support agent_workspace_write permission mode safely; use full_access")
	}
	args := []string{
		"--allow-dangerously-skip-permissions",
		"--dangerously-skip-permissions",
		"--verbose",
		"--output-format", "stream-json",
		"--input-format", "stream-json",
		"--replay-user-messages",
	}
	if cfg.DisableNativeTools {
		// Claude's --tools flag controls built-in tools only; MCP tools remain
		// available from the explicit config below. Strict MCP loading prevents
		// ambient user configuration from widening the launch tool set.
		args = append(args, "--tools", "", "--disable-slash-commands")
		if len(cfg.MCPs) > 0 {
			args = append(args, "--strict-mcp-config")
		}
	}
	var cleanup func()
	addCleanup := func(fn func()) {
		if fn == nil {
			return
		}
		previous := cleanup
		cleanup = func() {
			fn()
			if previous != nil {
				previous()
			}
		}
	}
	if strings.TrimSpace(cfg.Model) != "" {
		args = append(args, "--model", strings.TrimSpace(cfg.Model))
	}
	if settings := buildClaudeSettingsOverrides(cfg); settings != "" {
		args = append(args, "--settings", settings)
	}
	if len(cfg.MCPs) > 0 {
		mcpConfigPath, err := writeClaudeMCPConfigFile(cfg)
		if err != nil {
			return drivercontract.SpawnResult{}, err
		}
		addCleanup(func() {
			logIgnoredCleanupError("failed to remove claude mcp config file", os.Remove(mcpConfigPath), "path", mcpConfigPath)
		})
		args = append(args, "--mcp-config", mcpConfigPath)
	}
	pendingRecovery, hasPendingRecovery, err := readClaudePendingRecovery(cfg.WorkDir)
	if err != nil {
		if cleanup != nil {
			cleanup()
		}
		return drivercontract.SpawnResult{}, err
	}
	var pendingRecoveryRef rewindcaps.ClaudeSessionResumeAtRecoveryRef
	if hasPendingRecovery {
		pendingRecoveryRef, err = claudeRecoveryRefForRestore(pendingRecovery.Strategy, pendingRecovery.RecoveryRef)
		if err != nil {
			if cleanup != nil {
				cleanup()
			}
			return drivercontract.SpawnResult{}, err
		}
	}

	// The runtime owns provider-session existence. Do not probe Claude's local
	// transcript before handing a saved session to `claude --resume`: transcript
	// storage may live elsewhere, may be lazily materialized, or may have been
	// created by another daemon. Claude CLI does not expose a deterministic,
	// structured pre-input invalid-session boundary, so ordinary resume is
	// fail-closed on a later stderr/exit failure and never fresh-replays the
	// initial input. Explicit rewind/resume-at validation remains in
	// driver_claude_recovery.go and is a separate recovery boundary.
	effectiveSessionID := strings.TrimSpace(cfg.SessionID)
	if hasPendingRecovery {
		effectiveSessionID = strings.TrimSpace(pendingRecoveryRef.SessionID)
	}
	launchSessionID := effectiveSessionID
	var launchBoundaryUUID string
	switch {
	case hasPendingRecovery && pendingRecoveryRef.BoundaryKind == rewindcaps.RecoveryBoundaryBeforeFirstInput:
		// Rewinding the first AgentTeam input has no previous Claude transcript
		// boundary. Use Claude's native fresh session contract and let the next
		// AgentTeam input become the first message in the replacement session.
		effectiveSessionID = ""
		launchSessionID = strings.ToLower(uuid.NewString())
		args = append(args, "--session-id", launchSessionID)
	case effectiveSessionID != "":
		args = append(args, "--resume", effectiveSessionID)
		if hasPendingRecovery {
			launchBoundaryUUID = strings.TrimSpace(pendingRecoveryRef.BoundaryUUID)
			args = append(args, "--resume-session-at", launchBoundaryUUID)
		}
	default:
		// Claude can accept a caller-provided session id for fresh sessions.
		// Supplying one makes the runtime session identity known before the
		// first AgentTeam message is written to stdin, which is required for a
		// first-message rewind recovery point. This is not a compatibility
		// shim: it is the native Claude CLI session-id contract.
		launchSessionID = strings.ToLower(uuid.NewString())
		args = append(args, "--session-id", launchSessionID)
	}
	args, err = appendClaudeSystemPromptArgs(args, appendSystemPrompt, runtime.GOOS, addCleanup)
	if err != nil {
		if cleanup != nil {
			cleanup()
		}
		return drivercontract.SpawnResult{}, err
	}
	for _, tool := range cfg.DisallowedTools {
		args = append(args, "--disallowedTools", tool)
	}

	binary := strings.TrimSpace(cfg.Binary)
	if binary == "" {
		binary = "claude"
	}
	if err := validateClaudeLaunchCommandLine(binary, args, runtime.GOOS); err != nil {
		if cleanup != nil {
			cleanup()
		}
		return drivercontract.SpawnResult{}, err
	}

	cmd := exec.CommandContext(ctx, binary, args...)
	cmd.Dir = cfg.WorkDir
	cmd.Env = buildClaudeEnv(runtimeconfig.RuntimeEnvOverrides(cfg.Env, cfg.MCPs), cfg.Provider, cfg.SmallModel)
	stderr := newCapturingPrefixWriter(fmt.Sprintf("[claude %s] ", cfg.AgentID), claudeCapturedStderrMaxBytes)
	cmd.Stderr = stderr
	configureRuntimeCommand(cmd)

	stdinPipe, err := cmd.StdinPipe()
	if err != nil {
		if cleanup != nil {
			cleanup()
		}
		return drivercontract.SpawnResult{}, fmt.Errorf("claude: get stdin pipe: %w", err)
	}

	stdoutPipe, err := cmd.StdoutPipe()
	if err != nil {
		if cleanup != nil {
			cleanup()
		}
		logIgnoredCleanupError("failed to close claude stdin pipe after stdout setup failure", stdinPipe.Close())
		return drivercontract.SpawnResult{}, fmt.Errorf("claude: get stdout pipe: %w", err)
	}

	if err := cmd.Start(); err != nil {
		if cleanup != nil {
			cleanup()
		}
		logIgnoredCleanupError("failed to close claude stdin pipe after start failure", stdinPipe.Close())
		logIgnoredCleanupError("failed to close claude stdout pipe after start failure", stdoutPipe.Close())
		return drivercontract.SpawnResult{}, fmt.Errorf("claude: start: %w", err)
	}

	d.mu.Lock()
	d.stdinPipe = stdinPipe
	d.sessionID = launchSessionID
	d.lastTranscriptUUID = launchBoundaryUUID
	d.slashCommandsDisabled = cfg.DisableNativeTools
	d.compactQueued = false
	d.compactionInProgress = false
	d.compactBoundaryObserved = false
	d.mu.Unlock()

	preparedRecoveryPoint := prepareSpawnRecoveryPoint(d, cfg.InitialDeliveryMessageID, launchSessionID)

	// Authoritative Claude no-prompt gate: write the initial stream-json frame only
	// when the resolved initial prompt is non-blank OR PromptAttachments are
	// present. A true no-text/no-attachment launch must not call the stdin
	// message builder, write stdin, or create an empty user turn; stdin stays
	// open for the daemon's first durable handoff to write the first real frame.
	resolvedInitialPrompt := resolveClaudeInitialPromptForSession(cfg, effectiveSessionID)
	hasInitialInput := strings.TrimSpace(resolvedInitialPrompt) != "" || len(cfg.PromptAttachments) > 0
	if hasInitialInput {
		promptMsg := buildClaudeStdinMsgWithOptions(resolvedInitialPrompt, effectiveSessionID, claudeStdinOptions{
			UUID: cfg.InitialDeliveryMessageID,
		})
		if _, err := io.WriteString(stdinPipe, string(promptMsg)+"\n"); err != nil {
			d.mu.Lock()
			d.stdinPipe = nil
			d.mu.Unlock()
			logIgnoredCleanupError("failed to kill claude process after prompt write failure", cmd.Process.Kill(), "pid", cmd.Process.Pid)
			if cleanup != nil {
				cleanup()
			}
			logIgnoredCleanupError("failed to close claude stdin pipe after prompt write failure", stdinPipe.Close())
			logIgnoredCleanupError("failed to close claude stdout pipe after prompt write failure", stdoutPipe.Close())
			return drivercontract.SpawnResult{}, fmt.Errorf("claude: write prompt: %w", err)
		}
	}
	if hasPendingRecovery {
		clearClaudePendingRecovery(cfg.WorkDir)
	}

	finalCleanup := cleanup
	cleanup = func() {
		d.mu.Lock()
		d.stdinPipe = nil
		d.mu.Unlock()
		if finalCleanup != nil {
			finalCleanup()
		}
	}

	return drivercontract.SpawnResult{
		Cleanup:               cleanup,
		Cmd:                   cmd,
		Stdin:                 stdinPipe,
		Stdout:                stdoutPipe,
		StderrTail:            stderr.Tail,
		SessionID:             launchSessionID,
		SessionResumed:        effectiveSessionID != "",
		PreparedRecoveryPoint: preparedRecoveryPoint,
	}, nil
}

// PrepareRecoveryPointForMessage is intentionally message-bound for Claude:
// the recovery boundary must be captured immediately before a AgentTeam message is
// written into Claude's stream-json stdin. Rewind uses Claude's native
// resume-at/fresh-session strategy; keeping this method scoped to boundary
// capture prevents future implementations from falling back to a stale global
// snapshot, and LAN E2E remains the release gate for the full runtime path.

func buildClaudeAppendSystemPrompt(cfg drivercontract.LaunchConfig) (string, error) {
	carrier, err := cfg.RequireCompiledPrompt()
	if err != nil {
		return "", err
	}
	text := carrier.LaunchSurface("systemPrompt")
	if text == "" {
		return "", nil
	}

	sizeBytes := len([]byte(text))
	if sizeBytes > claudeAppendSystemPromptMaxBytes {
		return "", fmt.Errorf(
			"claude: appended system prompt too large: %d bytes exceeds limit %d",
			sizeBytes,
			claudeAppendSystemPromptMaxBytes,
		)
	}
	if sizeBytes > claudeAppendSystemPromptWarnBytes {
		slog.Warn(
			"claude appended system prompt is large",
			"agent_id", cfg.AgentID,
			"bytes", sizeBytes,
			"warn_bytes", claudeAppendSystemPromptWarnBytes,
			"limit_bytes", claudeAppendSystemPromptMaxBytes,
		)
	}
	return text, nil
}

func appendClaudeSystemPromptArgs(args []string, appendSystemPrompt, goos string, addCleanup func(func())) ([]string, error) {
	if appendSystemPrompt == "" {
		return args, nil
	}
	if goos != "windows" {
		return append(args, "--append-system-prompt", appendSystemPrompt), nil
	}
	systemPromptPath, err := writeClaudeAppendSystemPromptFile(appendSystemPrompt)
	if err != nil {
		return nil, err
	}
	addCleanup(func() {
		logIgnoredCleanupError("failed to remove claude system prompt file", os.Remove(systemPromptPath), "path", systemPromptPath)
	})
	return append(args, "--append-system-prompt-file", systemPromptPath), nil
}

func writeClaudeAppendSystemPromptFile(text string) (string, error) {
	file, err := os.CreateTemp("", "agentteam-claude-system-prompt-*.md")
	if err != nil {
		return "", fmt.Errorf("claude: create system prompt file: %w", err)
	}
	path := file.Name()
	if _, err := file.WriteString(text); err != nil {
		logIgnoredCleanupError("failed to close claude system prompt file after write error", file.Close(), "path", path)
		logIgnoredCleanupError("failed to remove claude system prompt file after write error", os.Remove(path), "path", path)
		return "", fmt.Errorf("claude: write system prompt file: %w", err)
	}
	if err := file.Close(); err != nil {
		logIgnoredCleanupError("failed to remove claude system prompt file after close error", os.Remove(path), "path", path)
		return "", fmt.Errorf("claude: close system prompt file: %w", err)
	}
	return path, nil
}

func validateClaudeLaunchCommandLine(binary string, args []string, goos string) error {
	if goos != "windows" {
		return nil
	}
	launchBinary := resolveClaudeWindowsLaunchBinary(binary)
	if !claudeWindowsLaunchUsesCmdShim(launchBinary) {
		return nil
	}
	length := estimateWindowsCommandLineChars(binary, args)
	if length <= claudeWindowsCommandLineMaxChars {
		return nil
	}
	return fmt.Errorf(
		"claude: AgentTeam generated Claude startup arguments exceed the Windows process launch limit (%d characters, limit %d). This happened after clicking Start; it is not a user shell command. AgentTeam sends generated system instructions through --append-system-prompt-file, so this indicates another launch argument is unexpectedly too large",
		length,
		claudeWindowsCommandLineMaxChars,
	)
}

func resolveClaudeWindowsLaunchBinary(binary string) string {
	if path, err := exec.LookPath(binary); err == nil && strings.TrimSpace(path) != "" {
		return path
	}
	return binary
}

func claudeWindowsLaunchUsesCmdShim(binary string) bool {
	base := strings.TrimSpace(binary)
	if i := strings.LastIndexAny(base, `\/`); i >= 0 {
		base = base[i+1:]
	}
	if i := strings.LastIndex(base, "."); i >= 0 {
		ext := strings.ToLower(base[i:])
		return ext == ".cmd" || ext == ".bat"
	}
	return true
}

func estimateWindowsCommandLineChars(binary string, args []string) int {
	length := windowsCommandLineArgChars(binary)
	for _, arg := range args {
		length += 1 + windowsCommandLineArgChars(arg)
	}
	return length
}

func windowsCommandLineArgChars(arg string) int {
	if arg == "" {
		return 2
	}
	needsQuote := false
	for _, r := range arg {
		if r == ' ' || r == '\t' || r == '"' {
			needsQuote = true
			break
		}
	}
	if !needsQuote {
		return len([]rune(arg))
	}

	length := 2 // surrounding quotes
	backslashes := 0
	for _, r := range arg {
		switch r {
		case '\\':
			backslashes++
		case '"':
			length += backslashes*2 + 2
			backslashes = 0
		default:
			length += backslashes + 1
			backslashes = 0
		}
	}
	length += backslashes * 2
	return length
}

func resolveClaudeInitialPrompt(cfg drivercontract.LaunchConfig) string {
	return resolveClaudeInitialPromptForSession(cfg, cfg.SessionID)
}

func resolveClaudeInitialPromptForSession(cfg drivercontract.LaunchConfig, sessionID string) string {
	if cfg.CompiledPrompt == nil || !cfg.CompiledPrompt.HasLaunchCarrier() {
		return ""
	}
	if strings.TrimSpace(sessionID) != "" {
		if text := strings.TrimSpace(cfg.CompiledPrompt.LaunchSurface("resumePrompt")); text != "" {
			return text
		}
	}
	if text := strings.TrimSpace(cfg.CompiledPrompt.LaunchSurface("freshPrompt")); text != "" {
		return text
	}
	return cfg.CompiledPrompt.LaunchSurface("prompt")
}
