package codex

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"sync/atomic"
	"time"
	"unicode/utf8"

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

// codexSession manages a multi-turn Codex conversation.
// First Send() uses `codex exec`, subsequent ones use `codex exec resume <threadID>`.
type codexSession struct {
	workDir        string
	model          string
	effort         string
	mode           string
	baseURL        string   // provider base URL; passed as -c openai_base_url=<url>
	modelProvider  string   // Codex model_provider name; passed as -c model_provider=<name>
	cmd            string   // CLI binary, default "codex"
	cliExtraArgs   []string // extra args from cmd, prepended before exec args
	extraEnv       []string
	promptPreamble string
	events         chan core.Event
	threadID       atomic.Value // stores string — Codex thread_id
	ctx            context.Context
	cancel         context.CancelFunc
	wg             sync.WaitGroup
	alive          atomic.Bool
	closeOnce      sync.Once
	cmdMu          sync.Mutex
	cmds           map[*exec.Cmd]struct{}

	pendingMsgs []string // buffered agent_message texts awaiting classification

	runtimeCfgMu       sync.Mutex
	runtimeCfgModel    string
	runtimeCfgEffort   string
	runtimeCfgFetched  time.Time
	runtimeCfgFetchErr error

	contextMu    sync.RWMutex
	contextUsage *core.ContextUsage
	sessionFile  string
}

var codexSessionCloseTimeout = 8 * time.Second
var codexSessionForceKillWait = 2 * time.Second
var codexRuntimeConfigCacheTTL = 5 * time.Second
var codexRuntimeConfigTimeout = 1500 * time.Millisecond
var codexContextUsageRetryDelay = 50 * time.Millisecond
var codexContextUsageRetryCount = 4

func buildCodexPromptPreamble(systemPrompt string, appendPrompt string) string {
	var sections []string
	if systemPrompt = strings.TrimSpace(systemPrompt); systemPrompt != "" {
		sections = append(sections, "Project system prompt:\n"+systemPrompt)
	}
	if appendPrompt = strings.TrimSpace(appendPrompt); appendPrompt != "" {
		sections = append(sections, "Additional project instructions:\n"+appendPrompt)
	}
	return strings.Join(sections, "\n\n")
}

func prependCodexPromptPreamble(prompt string, preamble string) string {
	preamble = strings.TrimSpace(preamble)
	if preamble == "" {
		return prompt
	}
	prompt = strings.TrimSpace(prompt)
	if prompt == "" {
		return "Before answering, follow these project-level instructions for this cc-connect session. They are not user content.\n\n" + preamble
	}
	return "Before answering, follow these project-level instructions for this cc-connect session. They are not user content.\n\n" + preamble + "\n\n---\n\nUser message:\n" + prompt
}

func newCodexSession(ctx context.Context, cliBin string, cliExtraArgs []string, workDir, model, effort, mode, resumeID, baseURL string, extraEnv []string, modelProvider string, systemPrompt string, appendPrompt string) (*codexSession, error) {
	sessionCtx, cancel := context.WithCancel(ctx)

	cs := &codexSession{
		workDir:        workDir,
		model:          model,
		effort:         effort,
		mode:           mode,
		baseURL:        baseURL,
		modelProvider:  modelProvider,
		cmd:            cliBin,
		cliExtraArgs:   cliExtraArgs,
		extraEnv:       extraEnv,
		promptPreamble: buildCodexPromptPreamble(systemPrompt, appendPrompt),
		events:         make(chan core.Event, 64),
		ctx:            sessionCtx,
		cancel:         cancel,
		cmds:           make(map[*exec.Cmd]struct{}),
	}
	cs.alive.Store(true)

	if resumeID != "" && resumeID != core.ContinueSession {
		cs.threadID.Store(resumeID)
	}

	return cs, nil
}

// Send launches a codex subprocess.
// If a threadID exists (from a prior turn or resume), uses `codex exec resume <id> <prompt>`.
// Otherwise uses `codex exec <prompt>` to start a new conversation.
func (cs *codexSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
	if len(files) > 0 {
		filePaths := core.SaveFilesToDisk(cs.workDir, files)
		prompt = core.AppendFileRefs(prompt, filePaths)
	}
	if !cs.alive.Load() {
		return fmt.Errorf("session is closed")
	}

	prompt, imagePaths, err := cs.stageImages(prompt, images)
	if err != nil {
		return err
	}

	isResume := cs.CurrentSessionID() != ""
	if !isResume {
		prompt = prependCodexPromptPreamble(prompt, cs.promptPreamble)
	}
	args := cs.buildExecArgs(prompt, imagePaths)
	if len(cs.cliExtraArgs) > 0 {
		args = append(append([]string{}, cs.cliExtraArgs...), args...)
	}

	bin := cs.cmd
	if bin == "" {
		bin = "codex"
	}

	slog.Debug("codexSession: launching", "resume", isResume, "args", core.RedactArgs(args))

	cmd := exec.CommandContext(cs.ctx, bin, args...)
	cmd.Dir = cs.workDir
	prepareCmdForKill(cmd)
	if len(cs.extraEnv) > 0 {
		cmd.Env = core.MergeEnv(os.Environ(), cs.extraEnv)
	}
	cmd.Stdin = strings.NewReader(prompt)

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return fmt.Errorf("codexSession: stdout pipe: %w", err)
	}

	var stderrBuf bytes.Buffer
	cmd.Stderr = &stderrBuf

	if err := cmd.Start(); err != nil {
		return fmt.Errorf("codexSession: start: %w", err)
	}
	cs.addCmd(cmd)

	cs.wg.Add(1)
	go cs.readLoop(cmd, stdout, &stderrBuf)

	return nil
}

func (cs *codexSession) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) {
	if len(images) == 0 {
		return prompt, nil, nil
	}

	imgDir := filepath.Join(cs.workDir, ".cc-connect", "images")
	if err := os.MkdirAll(imgDir, 0o755); err != nil {
		return "", nil, fmt.Errorf("codexSession: create image dir: %w", err)
	}

	imagePaths := make([]string, 0, len(images))
	for i, img := range images {
		ext := codexImageExt(img.MimeType)
		fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
		fpath := filepath.Join(imgDir, fname)
		if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
			return "", nil, fmt.Errorf("codexSession: save image: %w", err)
		}
		imagePaths = append(imagePaths, fpath)
	}

	if strings.TrimSpace(prompt) == "" {
		prompt = "Please analyze the attached image(s)."
	}

	return prompt, imagePaths, nil
}

func (cs *codexSession) buildExecArgs(prompt string, imagePaths []string) []string {
	tid := cs.CurrentSessionID()
	isResume := tid != ""

	var args []string
	if isResume {
		// For resume: codex exec resume ... <thread_id> [--image ...] --json --cd <dir> <prompt>
		// The codex CLI requires --json after the thread_id positional argument.
		args = []string{"exec", "resume", "--skip-git-repo-check"}
	} else {
		args = []string{"exec", "--skip-git-repo-check"}
	}

	// Mode → sandbox + approval mapping.
	//
	// `codex exec` (this backend) has no approval IPC, so approval_policy must
	// always be "never" — otherwise the CLI blocks waiting for a TTY response
	// that never arrives. Sandbox tier is what actually enforces safety here.
	//
	// As of codex-cli 0.137 `--full-auto` is no longer accepted; use the
	// canonical `--sandbox <mode>` form together with `-c approval_policy=...`.
	//
	// CAVEAT: `codex exec resume` does NOT accept the `--sandbox <mode>` flag
	// (only `codex exec` does). Both subcommands accept `-c key=value` config
	// overrides though, so on resume we express sandbox via `-c sandbox_mode=...`
	// instead. Without this, every resume would fail with:
	//   error: unexpected argument '--sandbox' found
	// — and the user would silently lose their session on every cc-connect
	// restart / idle reset.
	//
	// For real interactive approvals (suggest semantics), users must opt into
	// the `app_server` backend, which handles execCommandApproval /
	// applyPatchApproval / permissionsApproval over JSON-RPC.
	switch cs.mode {
	case "auto-edit", "full-auto":
		if isResume {
			args = append(args, "-c", `sandbox_mode="workspace-write"`, "-c", `approval_policy="never"`)
		} else {
			args = append(args, "--sandbox", "workspace-write", "-c", `approval_policy="never"`)
		}
	case "yolo":
		args = append(args, "--dangerously-bypass-approvals-and-sandbox")
	default: // "suggest"
		if isResume {
			args = append(args, "-c", `sandbox_mode="read-only"`, "-c", `approval_policy="never"`)
		} else {
			args = append(args, "--sandbox", "read-only", "-c", `approval_policy="never"`)
		}
	}

	if cs.model != "" {
		args = append(args, "--model", cs.model)
	}
	if cs.modelProvider != "" {
		args = append(args, "-c", fmt.Sprintf("model_provider=%q", cs.modelProvider))
	}
	if cs.baseURL != "" {
		args = append(args, "-c", fmt.Sprintf("openai_base_url=%q", cs.baseURL))
	}
	if cs.effort != "" {
		args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", cs.effort))
	}

	if isResume {
		args = append(args, tid)
		for _, imagePath := range imagePaths {
			args = append(args, "--image", imagePath)
		}
		// codex exec resume does not support --cd; cmd.Dir handles cwd instead.
		// Use stdin ("-") so multiline prompts are preserved reliably on Windows.
		args = append(args, "--json", "-")
	} else {
		for _, imagePath := range imagePaths {
			args = append(args, "--image", imagePath)
		}
		args = append(args, "--json", "--cd", cs.workDir, "-")
	}
	return args
}

func codexImageExt(mime string) string {
	switch mime {
	case "image/jpeg":
		return ".jpg"
	case "image/gif":
		return ".gif"
	case "image/webp":
		return ".webp"
	default:
		return ".png"
	}
}

func (cs *codexSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
	defer cs.wg.Done()
	defer func() {
		defer cs.removeCmd(cmd)
		if err := cmd.Wait(); err != nil {
			stderrMsg := strings.TrimSpace(stderrBuf.String())
			if stderrMsg != "" {
				slog.Error("codexSession: process failed", "error", err, "stderr", stderrMsg)
				evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
				select {
				case cs.events <- evt:
				case <-cs.ctx.Done():
					return
				}
			}
		}
		if tid := cs.CurrentSessionID(); tid != "" {
			patchSessionSource(tid, getenvFromList(cs.extraEnv, "CODEX_HOME"))
		}
	}()

	if err := readJSONLines(stdout, func(line []byte) error {
		lineText := string(line)
		if lineText == "" {
			return nil
		}

		slog.Debug("codexSession: raw", "line", truncate(lineText, 500))

		var raw map[string]any
		if err := json.Unmarshal(line, &raw); err != nil {
			slog.Debug("codexSession: non-JSON line", "line", lineText)
			return nil
		}

		cs.handleEvent(raw)
		return nil
	}); err != nil {
		slog.Error("codexSession: read stdout error", "error", err)
		evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
		select {
		case cs.events <- evt:
		case <-cs.ctx.Done():
			return
		}
	}
}

func readJSONLines(r io.Reader, handle func([]byte) error) error {
	reader := bufio.NewReader(r)

	for {
		line, err := reader.ReadBytes('\n')
		if errors.Is(err, io.EOF) && len(line) == 0 {
			return nil
		}
		if err != nil && !errors.Is(err, io.EOF) {
			return err
		}

		line = bytes.TrimRight(line, "\r\n")
		if len(line) > 0 {
			if err := handle(line); err != nil {
				return err
			}
		}

		if errors.Is(err, io.EOF) {
			return nil
		}
	}
}

func (cs *codexSession) handleEvent(raw map[string]any) {
	eventType, _ := raw["type"].(string)

	switch eventType {
	case "thread.started":
		if tid, ok := raw["thread_id"].(string); ok {
			cs.threadID.Store(tid)
			cs.contextMu.Lock()
			cs.sessionFile = ""
			cs.contextUsage = nil
			cs.contextMu.Unlock()
			slog.Debug("codexSession: thread started", "thread_id", tid)
		}

	case "turn.started":
		cs.pendingMsgs = cs.pendingMsgs[:0]
		cs.contextMu.Lock()
		cs.contextUsage = nil
		cs.contextMu.Unlock()
		slog.Debug("codexSession: turn started")

	case "item.started":
		cs.handleItemStarted(raw)

	case "item.completed":
		cs.handleItemCompleted(raw)

	case "turn.completed":
		cs.refreshContextUsageFromRollout()
		cs.flushPendingAsText()
		evt := core.Event{Type: core.EventResult, SessionID: cs.CurrentSessionID(), Done: true}
		select {
		case cs.events <- evt:
		case <-cs.ctx.Done():
			return
		}

	case "turn.failed":
		errMsg := ""
		if errObj, ok := raw["error"].(map[string]any); ok {
			errMsg, _ = errObj["message"].(string)
		}
		if errMsg == "" {
			errMsg = "turn failed (no details)"
		}
		slog.Warn("codexSession: turn failed", "error", errMsg)
		evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", errMsg)}
		select {
		case cs.events <- evt:
		case <-cs.ctx.Done():
			return
		}

	case "error":
		msg, _ := raw["message"].(string)
		if strings.Contains(msg, "Reconnecting") || strings.Contains(msg, "Falling back") {
			slog.Debug("codexSession: transient error", "message", msg)
		} else {
			slog.Warn("codexSession: error event", "message", msg)
		}

	default:
		slog.Debug("codexSession: unhandled event type", "type", eventType)
	}
}

// flushPendingAsThinking emits all buffered agent_messages as EventThinking.
func (cs *codexSession) flushPendingAsThinking() {
	if cs.ctx.Err() != nil {
		return
	}
	for _, text := range cs.pendingMsgs {
		if cs.ctx.Err() != nil {
			return
		}
		evt := core.Event{Type: core.EventThinking, Content: text}
		select {
		case cs.events <- evt:
		case <-cs.ctx.Done():
			return
		}
	}
	cs.pendingMsgs = cs.pendingMsgs[:0]
}

// flushPendingAsText emits all buffered agent_messages as EventText (final response).
func (cs *codexSession) flushPendingAsText() {
	if cs.ctx.Err() != nil {
		return
	}
	for _, text := range cs.pendingMsgs {
		if cs.ctx.Err() != nil {
			return
		}
		evt := core.Event{Type: core.EventText, Content: text}
		select {
		case cs.events <- evt:
		case <-cs.ctx.Done():
			return
		}
	}
	cs.pendingMsgs = cs.pendingMsgs[:0]
}

var codexToolNames = map[string]string{
	"web_search":       "WebSearch",
	"file_search":      "FileSearch",
	"code_interpreter": "CodeInterpreter",
	"computer_use":     "ComputerUse",
	"mcp_tool":         "MCP",
}

func (cs *codexSession) handleItemStarted(raw map[string]any) {
	item, ok := raw["item"].(map[string]any)
	if !ok {
		slog.Debug("codexSession: item.started missing item field")
		return
	}
	itemType, _ := item["type"].(string)
	slog.Debug("codexSession: item.started", "item_type", itemType)

	if itemType == "agent_message" || itemType == "message" || itemType == "reasoning" {
		return
	}

	// Any non-message item is a tool use; flush pending messages as thinking first.
	cs.flushPendingAsThinking()

	switch itemType {
	case "command_execution":
		command, _ := item["command"].(string)
		evt := core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: command}
		select {
		case cs.events <- evt:
		case <-cs.ctx.Done():
			return
		}
	case "function_call":
		name, _ := item["name"].(string)
		args, _ := item["arguments"].(string)
		evt := core.Event{Type: core.EventToolUse, ToolName: name, ToolInput: args}
		select {
		case cs.events <- evt:
		case <-cs.ctx.Done():
			return
		}
	}
	// Other tool types (web_search etc.) have empty fields at start;
	// their EventToolUse is emitted from handleItemCompleted instead.
}

func (cs *codexSession) handleItemCompleted(raw map[string]any) {
	item, ok := raw["item"].(map[string]any)
	if !ok {
		slog.Debug("codexSession: item.completed missing item field")
		return
	}
	itemType, _ := item["type"].(string)
	slog.Debug("codexSession: item.completed", "item_type", itemType)

	switch itemType {
	case "reasoning":
		text := extractItemText(item, "summary", "summary_text")
		if text != "" {
			evt := core.Event{Type: core.EventThinking, Content: text}
			select {
			case cs.events <- evt:
			case <-cs.ctx.Done():
				return
			}
		}

	case "agent_message", "message":
		text := extractItemText(item, "content", "output_text")
		if text != "" {
			cs.pendingMsgs = append(cs.pendingMsgs, text)
		}

	case "command_execution":
		command, _ := item["command"].(string)
		status, _ := item["status"].(string)
		output, _ := item["aggregated_output"].(string)
		exitCode, _ := item["exit_code"].(float64)
		code := int(exitCode)
		success := codexToolSuccess(status, &code)

		slog.Debug("codexSession: command completed",
			"command", truncate(command, 100),
			"status", status,
			"exit_code", code,
			"output_len", len(output),
		)
		evt := core.Event{
			Type:         core.EventToolResult,
			ToolName:     "Bash",
			ToolResult:   truncate(strings.TrimSpace(output), 500),
			ToolStatus:   strings.TrimSpace(status),
			ToolExitCode: &code,
			ToolSuccess:  &success,
		}
		select {
		case cs.events <- evt:
		case <-cs.ctx.Done():
			return
		}

	case "function_call":
		name, _ := item["name"].(string)
		status, _ := item["status"].(string)
		output, _ := item["output"].(string)
		success := codexToolSuccess(status, nil)
		slog.Debug("codexSession: function_call completed",
			"name", name, "status", status, "output_len", len(output),
		)
		evt := core.Event{
			Type:        core.EventToolResult,
			ToolName:    name,
			ToolResult:  truncate(strings.TrimSpace(output), 500),
			ToolStatus:  strings.TrimSpace(status),
			ToolSuccess: &success,
		}
		select {
		case cs.events <- evt:
		case <-cs.ctx.Done():
			return
		}

	case "function_call_output":
		slog.Debug("codexSession: function_call_output")

	case "error":
		msg, _ := item["message"].(string)
		if msg != "" && !strings.Contains(msg, "Falling back") {
			slog.Warn("codexSession: item error", "message", msg)
		}

	default:
		if toolName, known := codexToolNames[itemType]; known {
			input := codexExtractToolInput(item)
			evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
			select {
			case cs.events <- evt:
			case <-cs.ctx.Done():
				return
			}
		} else {
			slog.Debug("codexSession: unhandled item type", "item_type", itemType)
		}
	}
}

// codexExtractToolInput extracts a human-readable input from a Codex tool item.
// For web_search, it reads action.queries[] or falls back to the top-level query.
func codexExtractToolInput(item map[string]any) string {
	if action, ok := item["action"].(map[string]any); ok {
		if queries, ok := action["queries"].([]any); ok && len(queries) > 0 {
			var parts []string
			for _, q := range queries {
				if s, ok := q.(string); ok && s != "" {
					parts = append(parts, s)
				}
			}
			if len(parts) > 0 {
				return strings.Join(parts, "\n")
			}
		}
		if q, _ := action["query"].(string); q != "" {
			return q
		}
	}
	if q, _ := item["query"].(string); q != "" {
		return q
	}
	if n, _ := item["name"].(string); n != "" {
		return n
	}
	return ""
}

func codexToolSuccess(status string, exitCode *int) bool {
	s := strings.ToLower(strings.TrimSpace(status))
	if exitCode != nil {
		return *exitCode == 0
	}
	return s == "completed" || s == "success" || s == "succeeded" || s == "ok"
}

func loadCodexRuntimeConfig(ctx context.Context, workDir string, extraEnv []string) (string, string, error) {
	cmd := exec.CommandContext(ctx, "codex", "app-server")
	cmd.Dir = workDir
	prepareCmdForKill(cmd)
	if len(extraEnv) > 0 {
		cmd.Env = core.MergeEnv(os.Environ(), extraEnv)
	}

	stdin, err := cmd.StdinPipe()
	if err != nil {
		return "", "", fmt.Errorf("runtime config stdin pipe: %w", err)
	}
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return "", "", fmt.Errorf("runtime config stdout pipe: %w", err)
	}
	var stderr bytes.Buffer
	cmd.Stderr = &stderr

	if err := cmd.Start(); err != nil {
		return "", "", fmt.Errorf("runtime config start app-server: %w", err)
	}
	defer func() {
		_ = stdin.Close()
		if cmd.Process != nil {
			_ = cmd.Process.Kill()
		}
		_ = cmd.Wait()
	}()

	reader := bufio.NewReader(stdout)
	nextID := int64(1)

	if err := rpcRequestOverIO(stdin, reader, nextID, "initialize", map[string]any{
		"clientInfo": map[string]any{
			"name":    "cc-connect-codex-runtime-config",
			"title":   "CC Connect Codex Runtime Config",
			"version": "0.1.0",
		},
	}, nil); err != nil {
		return "", "", err
	}
	nextID++

	if err := rpcNotifyOverIO(stdin, "initialized", map[string]any{}); err != nil {
		return "", "", err
	}

	var resp struct {
		Config struct {
			Model                string  `json:"model"`
			ModelReasoningEffort *string `json:"model_reasoning_effort"`
		} `json:"config"`
	}
	if err := rpcRequestOverIO(stdin, reader, nextID, "config/read", map[string]any{
		"includeLayers": false,
	}, &resp); err != nil {
		return "", "", err
	}

	return strings.TrimSpace(resp.Config.Model), normalizeRuntimeReasoningEffort(stringValue(resp.Config.ModelReasoningEffort)), nil
}

func rpcRequestOverIO(stdin io.Writer, reader *bufio.Reader, id int64, method string, params any, out any) error {
	payload := map[string]any{
		"jsonrpc": "2.0",
		"id":      id,
		"method":  method,
		"params":  params,
	}
	if err := writeRPCMessage(stdin, payload); err != nil {
		return err
	}

	for {
		line, err := reader.ReadBytes('\n')
		if err != nil {
			return fmt.Errorf("%s read response: %w", method, err)
		}

		var probe map[string]json.RawMessage
		if err := json.Unmarshal(bytes.TrimSpace(line), &probe); err != nil {
			continue
		}
		if _, ok := probe["id"]; !ok {
			continue
		}

		var resp rpcResponseEnvelope
		if err := json.Unmarshal(bytes.TrimSpace(line), &resp); err != nil {
			continue
		}
		respID, ok := rpcIDToInt64(resp.ID)
		if !ok || respID != id {
			continue
		}
		if resp.Error != nil {
			return fmt.Errorf("%s: %s", method, strings.TrimSpace(resp.Error.Message))
		}
		if out != nil {
			if err := json.Unmarshal(resp.Result, out); err != nil {
				return fmt.Errorf("%s decode response: %w", method, err)
			}
		}
		return nil
	}
}

func rpcNotifyOverIO(stdin io.Writer, method string, params any) error {
	payload := map[string]any{
		"jsonrpc": "2.0",
		"method":  method,
		"params":  params,
	}
	return writeRPCMessage(stdin, payload)
}

func writeRPCMessage(w io.Writer, payload any) error {
	b, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("encode rpc message: %w", err)
	}
	if _, err := w.Write(append(b, '\n')); err != nil {
		return fmt.Errorf("write rpc message: %w", err)
	}
	return nil
}

// RespondPermission is a no-op for Codex — permissions are handled via CLI flags.
func (cs *codexSession) RespondPermission(_ string, _ core.PermissionResult) error {
	return nil
}

func (cs *codexSession) Events() <-chan core.Event {
	return cs.events
}

func (cs *codexSession) CurrentSessionID() string {
	v, _ := cs.threadID.Load().(string)
	return v
}

func (cs *codexSession) GetWorkDir() string {
	return cs.workDir
}

func (cs *codexSession) GetModel() string {
	if model := strings.TrimSpace(cs.model); model != "" {
		return model
	}
	model, _ := cs.runtimeConfig()
	return model
}

func (cs *codexSession) GetReasoningEffort() string {
	if effort := strings.TrimSpace(cs.effort); effort != "" {
		return effort
	}
	_, effort := cs.runtimeConfig()
	return effort
}

func (cs *codexSession) Alive() bool {
	return cs.alive.Load()
}

func (cs *codexSession) GetContextUsage() *core.ContextUsage {
	cs.contextMu.RLock()
	defer cs.contextMu.RUnlock()
	return cloneContextUsage(cs.contextUsage)
}

func (cs *codexSession) runtimeConfig() (string, string) {
	cs.runtimeCfgMu.Lock()
	defer cs.runtimeCfgMu.Unlock()

	if !cs.runtimeCfgFetched.IsZero() && time.Since(cs.runtimeCfgFetched) < codexRuntimeConfigCacheTTL {
		return cs.runtimeCfgModel, cs.runtimeCfgEffort
	}

	ctx, cancel := context.WithTimeout(cs.ctx, codexRuntimeConfigTimeout)
	defer cancel()

	model, effort, err := loadCodexRuntimeConfig(ctx, cs.workDir, cs.extraEnv)
	if err == nil {
		cs.runtimeCfgModel = model
		cs.runtimeCfgEffort = effort
		cs.runtimeCfgFetchErr = nil
		cs.runtimeCfgFetched = time.Now()
		return model, effort
	}

	cs.runtimeCfgFetchErr = err
	if !cs.runtimeCfgFetched.IsZero() {
		return cs.runtimeCfgModel, cs.runtimeCfgEffort
	}
	return "", ""
}

func (cs *codexSession) refreshContextUsageFromRollout() {
	sessionID := strings.TrimSpace(cs.CurrentSessionID())
	if sessionID == "" {
		return
	}

	for attempt := 0; attempt < codexContextUsageRetryCount; attempt++ {
		cs.contextMu.RLock()
		cachedPath := cs.sessionFile
		cs.contextMu.RUnlock()

		usage, path, err := loadContextUsageFromRollout(cs.extraEnv, sessionID, cachedPath)
		if err == nil && usage != nil {
			cs.contextMu.Lock()
			cs.sessionFile = path
			cs.contextUsage = cloneContextUsage(usage)
			cs.contextMu.Unlock()
			return
		}

		if attempt == codexContextUsageRetryCount-1 {
			if err != nil {
				slog.Debug("codexSession: context usage unavailable", "thread_id", sessionID, "error", err)
			}
			return
		}

		select {
		case <-time.After(codexContextUsageRetryDelay):
		case <-cs.ctx.Done():
			return
		}
	}
}

func (cs *codexSession) Close() error {
	cs.alive.Store(false)
	cs.cancel()
	done := make(chan struct{})
	go func() {
		cs.wg.Wait()
		close(done)
	}()
	select {
	case <-done:
		// readLoop has exited; safe to close the events channel.
		cs.closeOnce.Do(func() {
			close(cs.events)
		})
		return nil
	case <-time.After(codexSessionCloseTimeout):
		cmds := cs.activeCmds()
		slog.Warn("codexSession: graceful close timed out, killing active process groups",
			"wait", codexSessionCloseTimeout,
			"count", len(cmds))
		if err := forceKillAllCmds(cmds); err != nil {
			slog.Debug("codexSession: force kill failed", "error", err)
		}
		select {
		case <-done:
			cs.closeOnce.Do(func() {
				close(cs.events)
			})
			return nil
		case <-time.After(codexSessionForceKillWait):
			// Do not close(cs.events) here: readLoop may still be in handleEvent
			// (e.g. turn.completed -> flushPendingAsText) and would panic on send.
			slog.Warn("codexSession: force kill wait timed out, deferring events channel close until readLoop exits",
				"wait", codexSessionForceKillWait)
			go func() {
				<-done
				cs.closeOnce.Do(func() {
					close(cs.events)
				})
			}()
			return nil
		}
	}
}

func (cs *codexSession) addCmd(cmd *exec.Cmd) {
	cs.cmdMu.Lock()
	defer cs.cmdMu.Unlock()
	cs.cmds[cmd] = struct{}{}
}

func (cs *codexSession) removeCmd(cmd *exec.Cmd) {
	cs.cmdMu.Lock()
	defer cs.cmdMu.Unlock()
	delete(cs.cmds, cmd)
}

func (cs *codexSession) activeCmds() []*exec.Cmd {
	cs.cmdMu.Lock()
	defer cs.cmdMu.Unlock()
	cmds := make([]*exec.Cmd, 0, len(cs.cmds))
	for cmd := range cs.cmds {
		cmds = append(cmds, cmd)
	}
	return cmds
}

func forceKillAllCmds(cmds []*exec.Cmd) error {
	var errs []error
	for _, cmd := range cmds {
		if err := forceKillCmd(cmd); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

// extractItemText extracts text from an item's array field (e.g. "summary" or "content").
// It looks for elements matching the given elementType and concatenates their "text" fields.
// Falls back to the item's top-level "text" field if the array is missing or empty.
func extractItemText(item map[string]any, arrayField, elementType string) string {
	if arr, ok := item[arrayField].([]any); ok {
		var parts []string
		for _, elem := range arr {
			m, ok := elem.(map[string]any)
			if !ok {
				continue
			}
			if elementType != "" {
				if t, _ := m["type"].(string); t != elementType {
					continue
				}
			}
			if t, _ := m["text"].(string); t != "" {
				parts = append(parts, t)
			}
		}
		if len(parts) > 0 {
			return strings.Join(parts, "\n")
		}
	}
	text, _ := item["text"].(string)
	return text
}

func truncate(s string, maxRunes int) string {
	if utf8.RuneCountInString(s) <= maxRunes {
		return s
	}
	return string([]rune(s)[:maxRunes]) + "..."
}
