package app

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
)

type RunResult struct {
	Message              string
	ThreadID             string
	RepositoryBaseSHA    string
	PRURL                string
	VerificationPRURL    string
	MergeSHA             string
	VerificationMergeSHA string
	ReplayRunID          string
	Verification         string
	WorktreePath         string
	Investigation        *SingleTicketInvestigation
}

type AgentRunner interface {
	Run(context.Context, Job) (RunResult, error)
}

type CodexRunner struct {
	config        Config
	contextLoader SingleTicketContextLoader
}

func NewCodexRunner(config Config) *CodexRunner {
	return &CodexRunner{
		config:        config,
		contextLoader: NewHTTPSingleTicketContextLoader(config.AnalysisBaseURL),
	}
}

func (r *CodexRunner) Run(ctx context.Context, job Job) (RunResult, error) {
	if job.Mode == JobModeTicket {
		return r.runSingleTicket(ctx, job)
	}
	return r.runStandard(ctx, job)
}

func (r *CodexRunner) runStandard(ctx context.Context, job Job) (RunResult, error) {
	worktree := filepath.Join(r.config.WorkspaceRoot, job.ID)
	if err := os.MkdirAll(r.config.WorkspaceRoot, 0o750); err != nil {
		return RunResult{}, err
	}
	if err := r.ensureRepository(ctx); err != nil {
		return RunResult{}, err
	}
	baseSHA, err := r.fetchFreshMain(ctx)
	if err != nil {
		return RunResult{}, fmt.Errorf("fetch target repository: %w", err)
	}
	branch := ""
	args := []string{"worktree", "add"}
	if job.Mode == JobModePR {
		branch = "auto-oncall/feishu-" + job.ID
		args = append(args, "-b", branch, worktree, "origin/main")
	} else {
		args = append(args, "--detach", worktree, "origin/main")
	}
	if err := r.git(ctx, r.config.RepoPath, args...); err != nil {
		return RunResult{}, fmt.Errorf("create task worktree: %w", err)
	}
	if err := r.verifyWorktreeBase(ctx, worktree, baseSHA); err != nil {
		return RunResult{WorktreePath: worktree, RepositoryBaseSHA: baseSHA}, err
	}
	result := RunResult{WorktreePath: worktree, RepositoryBaseSHA: baseSHA}
	message, threadID, err := r.runCodex(ctx, job, worktree, agentPrompt(job), true, nil, "result")
	result.Message = message
	result.ThreadID = threadID
	if err != nil {
		return result, err
	}
	if job.Mode == JobModeAnalyze {
		if err := r.removeWorktree(ctx, worktree, ""); err != nil {
			return result, err
		}
		result.WorktreePath = ""
		return result, nil
	}
	paths, err := changedPaths(ctx, worktree)
	if err != nil {
		return result, err
	}
	if len(paths) == 0 {
		return result, errors.New("Codex completed without changing auto-oncall-services")
	}
	if err := validateChangedPaths(paths); err != nil {
		return result, err
	}
	if err := r.git(ctx, worktree, "add", "--", "auto-oncall-services"); err != nil {
		return result, err
	}
	if err := r.git(ctx, worktree, "-c", "user.name=Auto Oncall Optimizer", "-c", "user.email=auto-oncall-optimizer@pingcap.com", "commit", "-m", prTitle(job.Request)); err != nil {
		return result, fmt.Errorf("commit optimizer change: %w", err)
	}
	if err := r.git(ctx, worktree, "push", "-u", "origin", branch); err != nil {
		return result, fmt.Errorf("push optimizer branch: %w", err)
	}
	prURL, err := r.command(ctx, worktree, r.config.GitHubBin, "pr", "create", "--repo", r.config.GitHubRepo, "--base", "main", "--head", branch, "--title", prTitle(job.Request), "--body", prBody(job, message))
	if err != nil {
		return result, fmt.Errorf("create pull request: %w", err)
	}
	result.PRURL = strings.TrimSpace(prURL)
	if err := r.removeWorktree(ctx, worktree, branch); err != nil {
		return result, err
	}
	result.WorktreePath = ""
	return result, nil
}

func (r *CodexRunner) runSingleTicket(ctx context.Context, job Job) (RunResult, error) {
	issueKey := strings.ToUpper(strings.TrimSpace(job.IssueKey))
	nomination := strings.TrimSpace(job.Request)
	if job.Trigger == JobTriggerFeedback {
		if !issueKeyPattern.MatchString(issueKey) || job.FeedbackID < 1 ||
			(job.FeedbackAction != "wrong" && job.FeedbackAction != "revise") || nomination == "" ||
			job.SourceJobID < 1 || strings.TrimSpace(job.AnswerFingerprint) == "" {
			return RunResult{}, errors.New("invalid feedback-triggered single-ticket job")
		}
	} else if job.Trigger == JobTriggerCandidate {
		if !issueKeyPattern.MatchString(issueKey) || job.CandidateID < 1 ||
			(job.CandidateSource != "human_better" && job.CandidateSource != "runtime") ||
			strings.TrimSpace(job.CandidateSignal) == "" || nomination == "" ||
			job.SourceJobID < 1 || job.SourceRunDBID < 1 || strings.TrimSpace(job.AnswerFingerprint) == "" {
			return RunResult{}, errors.New("invalid automatic single-ticket candidate job")
		}
	} else {
		parsedIssueKey, parsedNomination, ok := parseSingleTicketRequest(job.Request)
		if !ok || parsedIssueKey != issueKey {
			return RunResult{}, errors.New("invalid single-ticket job")
		}
		nomination = parsedNomination
	}
	contextPacket, err := r.contextLoader.Load(ctx, issueKey, nomination, job.SourceJobID, job.AnswerFingerprint)
	if err != nil {
		return RunResult{}, err
	}
	contextPacket.Trigger = job.Trigger
	contextPacket.FeedbackID = job.FeedbackID
	contextPacket.FeedbackAction = job.FeedbackAction
	contextPacket.CandidateSource = job.CandidateSource
	contextPacket.CandidateSignal = job.CandidateSignal
	if err := r.ensureRepository(ctx); err != nil {
		return RunResult{}, err
	}
	baseSHA, err := r.fetchFreshMain(ctx)
	if err != nil {
		return RunResult{}, fmt.Errorf("fetch target repository: %w", err)
	}
	worktree := filepath.Join(r.config.WorkspaceRoot, job.ID)
	branch := "auto-oncall/ticket-" + strings.ToLower(issueKey) + "-" + job.ID
	if err := os.MkdirAll(r.config.WorkspaceRoot, 0o750); err != nil {
		return RunResult{}, err
	}
	if err := r.git(ctx, r.config.RepoPath, "worktree", "add", "-b", branch, worktree, "origin/main"); err != nil {
		return RunResult{}, fmt.Errorf("create single-ticket worktree: %w", err)
	}
	if err := r.verifyWorktreeBase(ctx, worktree, baseSHA); err != nil {
		return RunResult{WorktreePath: worktree, RepositoryBaseSHA: baseSHA}, err
	}
	result := RunResult{WorktreePath: worktree, RepositoryBaseSHA: baseSHA}
	contextPacket.RepositoryBaseSHA = baseSHA
	contextPath := filepath.Join(worktree, singleTicketContextFile)
	contextJSON, err := json.MarshalIndent(contextPacket, "", "  ")
	if err != nil {
		return result, err
	}
	if err := os.WriteFile(contextPath, append(contextJSON, '\n'), 0o400); err != nil {
		return result, fmt.Errorf("write single-ticket context: %w", err)
	}
	investigationRaw, threadID, err := r.runCodex(
		ctx, job, worktree, singleTicketInvestigationPrompt(job), true, singleTicketInvestigationSchema(), "investigation",
	)
	result.Message = investigationRaw
	result.ThreadID = threadID
	if err != nil {
		return result, err
	}
	investigationPaths, err := changedPaths(ctx, worktree)
	if err != nil {
		return result, err
	}
	if err := validateInvestigationPaths(investigationPaths); err != nil {
		return result, err
	}
	investigation, investigationRaw, err := parseSingleTicketInvestigationWithCorrection(
		investigationRaw,
		issueKey,
		contextPacket.AnswerVersion,
		func(prompt string) (string, error) {
			if result.ThreadID == "" {
				return "", errors.New("single-ticket investigation thread is unavailable for bounded correction")
			}
			corrected, correctionThreadID, correctionErr := r.runCodexContinuation(
				ctx, job, worktree, prompt, false, singleTicketInvestigationSchema(), "investigation-correction", result.ThreadID,
			)
			if correctionThreadID != "" {
				result.ThreadID = correctionThreadID
			}
			return corrected, correctionErr
		},
		func(investigation *SingleTicketInvestigation) error {
			return validateSingleTicketRuntimeOwner(contextPacket.Replay, investigation)
		},
	)
	result.Message = investigationRaw
	if err != nil {
		return result, err
	}
	result.Investigation = investigation
	selectedFinding := investigation.selectedRepairFinding()
	if selectedFinding == nil {
		_ = os.Remove(contextPath)
		if err := r.removeWorktree(ctx, worktree, branch); err != nil {
			return result, err
		}
		result.WorktreePath = ""
		result.Message = singleTicketOutcomeMessage(investigation, "")
		return result, nil
	}
	if err := validateImplementationTargets(worktree, selectedFinding.RepairDecision.ImplementationTargets); err != nil {
		return result, err
	}
	investigationPath := filepath.Join(worktree, singleTicketInvestigationFile)
	investigationJSON, err := json.MarshalIndent(investigation, "", "  ")
	if err != nil {
		return result, err
	}
	if err := os.WriteFile(investigationPath, append(investigationJSON, '\n'), 0o400); err != nil {
		return result, fmt.Errorf("write single-ticket investigation: %w", err)
	}
	repairMessage, repairThreadID, err := r.runCodex(
		ctx, job, worktree, singleTicketRepairPrompt(job), true, nil, "repair",
	)
	if repairThreadID != "" {
		result.ThreadID = repairThreadID
	}
	result.Message = repairMessage
	if err != nil {
		return result, err
	}
	if err := os.Chmod(contextPath, 0o600); err == nil {
		_ = os.Remove(contextPath)
	}
	if err := os.Chmod(investigationPath, 0o600); err == nil {
		_ = os.Remove(investigationPath)
	}
	paths, err := changedPaths(ctx, worktree)
	if err != nil {
		return result, err
	}
	if len(paths) == 0 {
		if err := r.removeWorktree(ctx, worktree, branch); err != nil {
			return result, err
		}
		result.WorktreePath = ""
		result.Message = singleTicketOutcomeMessage(investigation, repairMessage)
		return result, nil
	}
	if err := validateChangedPaths(paths); err != nil {
		return result, err
	}
	if validationErr := r.validateSingleTicketCandidate(ctx, worktree); validationErr != nil {
		if repairThreadID == "" {
			return result, validationErr
		}
		preValidationPaths := append([]string(nil), paths...)
		validationMessage, validationThreadID, validationRepairErr := r.runCodexContinuation(
			ctx, job, worktree, singleTicketValidationRepairPrompt(issueKey, validationErr), true, nil, "repair-validation", repairThreadID,
		)
		if validationThreadID != "" {
			result.ThreadID = validationThreadID
		}
		result.Message = strings.TrimSpace(repairMessage + "\n\n验证修复：\n" + validationMessage)
		if validationRepairErr != nil {
			return result, fmt.Errorf("repair single-ticket validation after %v: %w", validationErr, validationRepairErr)
		}
		paths, err = changedPaths(ctx, worktree)
		if err != nil {
			return result, err
		}
		if len(paths) == 0 {
			if err := r.removeWorktree(ctx, worktree, branch); err != nil {
				return result, err
			}
			result.WorktreePath = ""
			result.Message = singleTicketOutcomeMessage(investigation, result.Message)
			return result, nil
		}
		if err := validateValidationRepairPaths(preValidationPaths, paths); err != nil {
			return result, err
		}
		if err := r.validateSingleTicketCandidate(ctx, worktree); err != nil {
			return result, fmt.Errorf("single-ticket candidate validation is still failing after one repair attempt: %w", err)
		}
		repairMessage = result.Message
	}
	if err := r.git(ctx, worktree, "add", "--", "auto-oncall-services"); err != nil {
		return result, err
	}
	if err := r.git(ctx, worktree, "-c", "user.name=Auto Oncall Optimizer", "-c", "user.email=auto-oncall-optimizer@pingcap.com", "commit", "-m", singleTicketPRTitle(job)); err != nil {
		return result, fmt.Errorf("commit single-ticket optimizer change: %w", err)
	}
	if err := r.git(ctx, worktree, "push", "-u", "origin", branch); err != nil {
		return result, fmt.Errorf("push single-ticket optimizer branch: %w", err)
	}
	prURL, err := r.command(ctx, worktree, r.config.GitHubBin, "pr", "create", "--repo", r.config.GitHubRepo, "--base", "main", "--head", branch, "--title", singleTicketPRTitle(job), "--body", singleTicketPRBody(job, investigation, repairMessage))
	if err != nil {
		return result, fmt.Errorf("create single-ticket optimizer pull request: %w", err)
	}
	result.PRURL = strings.TrimSpace(prURL)
	mergeSHA, err := r.mergeAndDeploySingleTicket(ctx, worktree, result.PRURL)
	if err != nil {
		return result, err
	}
	result.MergeSHA = mergeSHA
	if err := os.WriteFile(investigationPath, append(investigationJSON, '\n'), 0o400); err != nil {
		return result, fmt.Errorf("restore single-ticket investigation for replay verification: %w", err)
	}
	replay, replayStatus, err := r.requestSingleTicketReplay(ctx, issueKey, "read")
	if err != nil {
		return result, err
	}
	assessment, verificationThreadID, err := r.assessSingleTicketReplay(ctx, job, worktree, investigation, replay)
	if verificationThreadID != "" {
		result.ThreadID = verificationThreadID
	}
	if err != nil {
		return result, err
	}
	result.Verification = assessment.Outcome + ": " + assessment.Reason
	if replayStatus < 200 || replayStatus >= 300 {
		return result, fmt.Errorf("post-deployment read replay status %d; verification: %s", replayStatus, assessment.Reason)
	}
	verificationWorktree := ""
	verificationBranch := ""
	if assessment.Outcome == "small_repairable" {
		followupPRURL, followupMergeSHA, followupWorktree, followupBranch, followupErr := r.continueSingleTicketRepairAfterReplay(
			ctx, job, investigation, repairThreadID, replay, assessment, paths,
		)
		if followupErr != nil {
			return result, fmt.Errorf("continue same-owner repair after deployed replay: %w", followupErr)
		}
		verificationWorktree = followupWorktree
		verificationBranch = followupBranch
		result.MergeSHA = followupMergeSHA
		result.Verification = fmt.Sprintf("small repair continued in %s after %s; ", followupPRURL, mergeSHA)
		replay, replayStatus, err = r.requestSingleTicketReplay(ctx, issueKey, "read")
		if err != nil {
			return result, err
		}
		assessment, verificationThreadID, err = r.assessSingleTicketReplay(
			ctx, job, verificationWorktree, investigation, replay,
		)
		if verificationThreadID != "" {
			result.ThreadID = verificationThreadID
		}
		if err != nil {
			return result, err
		}
		result.Verification += assessment.Outcome + ": " + assessment.Reason
		if replayStatus < 200 || replayStatus >= 300 {
			return result, fmt.Errorf("second post-deployment read replay status %d; verification: %s", replayStatus, assessment.Reason)
		}
	}
	if assessment.Outcome != "passed" {
		return result, fmt.Errorf("post-deployment replay requires human handoff after bounded repair policy: %s", assessment.Reason)
	}
	readRunID, err := readReplayRunID(replay, replayStatus, issueKey)
	if err != nil {
		return result, err
	}
	published, publishStatus, err := r.publishSingleTicketReadReplay(ctx, issueKey, readRunID)
	if err != nil {
		return result, err
	}
	publicationVerification, err := validateSingleTicketReadPublication(published, publishStatus, readRunID, issueKey)
	if err != nil {
		return result, err
	}
	result.ReplayRunID = publicationVerification.RunID
	if verificationWorktree != "" {
		_ = os.Chmod(filepath.Join(verificationWorktree, singleTicketInvestigationFile), 0o600)
		_ = os.Remove(filepath.Join(verificationWorktree, singleTicketInvestigationFile))
		_ = os.Chmod(filepath.Join(verificationWorktree, singleTicketAfterReplayFile), 0o600)
		_ = os.Remove(filepath.Join(verificationWorktree, singleTicketAfterReplayFile))
		if err := r.removeWorktree(ctx, verificationWorktree, verificationBranch); err != nil {
			return result, err
		}
	}
	verificationPRURL, verificationMergeSHA, err := r.recordSingleTicketOptimizedVerified(
		ctx, job, paths, publicationVerification, result.MergeSHA,
	)
	if err != nil {
		return result, err
	}
	result.VerificationPRURL = verificationPRURL
	result.VerificationMergeSHA = verificationMergeSHA
	result.Message = singleTicketOutcomeMessage(investigation, repairMessage) +
		fmt.Sprintf("\n\n自动验证：修复已合并并部署 `%s`；post-change read replay 通过并原样发布，Jira 内部评论和评价已更新（run `%s`）；`optimized_verified` 已合并并部署 `%s`。", result.MergeSHA, publicationVerification.RunID, verificationMergeSHA)
	_ = os.Remove(investigationPath)
	_ = os.Remove(filepath.Join(worktree, singleTicketAfterReplayFile))
	if err := r.removeWorktree(ctx, worktree, branch); err != nil {
		return result, err
	}
	result.WorktreePath = ""
	return result, nil
}

func (r *CodexRunner) validateSingleTicketCandidate(ctx context.Context, worktree string) error {
	serviceRoot := filepath.Join(worktree, "auto-oncall-services")
	if _, err := os.Stat(filepath.Join(serviceRoot, "node_modules")); errors.Is(err, os.ErrNotExist) {
		if _, err := commandOutputWithEnv(ctx, serviceRoot, r.validationEnvironment(), "npm", "ci"); err != nil {
			return fmt.Errorf("install single-ticket candidate dependencies: %w", err)
		}
	} else if err != nil {
		return fmt.Errorf("inspect single-ticket candidate dependencies: %w", err)
	}
	if _, err := commandOutputWithEnv(ctx, serviceRoot, r.validationEnvironment(), "npm", "test"); err != nil {
		return fmt.Errorf("validate single-ticket candidate: %w", err)
	}
	return nil
}

func (r *CodexRunner) validationEnvironment() []string {
	allowed := []string{"PATH", "LANG", "LC_ALL", "SSL_CERT_FILE", "SSL_CERT_DIR"}
	env := make([]string, 0, len(allowed)+1)
	for _, name := range allowed {
		if value, ok := os.LookupEnv(name); ok {
			env = append(env, name+"="+value)
		}
	}
	validationHome := filepath.Join(r.config.WorkspaceRoot, "validation-home")
	_ = os.MkdirAll(validationHome, 0o700)
	env = append(env, "HOME="+validationHome)
	sort.Strings(env)
	return env
}

func (r *CodexRunner) runCodex(
	ctx context.Context,
	job Job,
	worktree string,
	prompt string,
	writable bool,
	outputSchema map[string]any,
	resultSuffix string,
) (string, string, error) {
	resumeThreadID := ""
	if job.Mode == JobModeAnalyze {
		resumeThreadID = job.ResumeThreadID
	}
	return r.runCodexCommand(ctx, job, worktree, prompt, writable, outputSchema, resultSuffix, resumeThreadID)
}

func (r *CodexRunner) runCodexContinuation(
	ctx context.Context,
	job Job,
	worktree string,
	prompt string,
	writable bool,
	outputSchema map[string]any,
	resultSuffix string,
	resumeThreadID string,
) (string, string, error) {
	return r.runCodexCommand(ctx, job, worktree, prompt, writable, outputSchema, resultSuffix, resumeThreadID)
}

func (r *CodexRunner) runCodexCommand(
	ctx context.Context,
	job Job,
	worktree string,
	prompt string,
	writable bool,
	outputSchema map[string]any,
	resultSuffix string,
	resumeThreadID string,
) (string, string, error) {
	resultPath := filepath.Join(r.config.WorkspaceRoot, job.ID+"-"+resultSuffix+".txt")
	schemaPath := ""
	if outputSchema != nil {
		schemaPath = filepath.Join(r.config.WorkspaceRoot, job.ID+"-"+resultSuffix+"-schema.json")
		schema, err := json.Marshal(outputSchema)
		if err != nil {
			return "", "", err
		}
		if err := os.WriteFile(schemaPath, schema, 0o600); err != nil {
			return "", "", err
		}
		defer os.Remove(schemaPath)
	}
	args := codexExecArgsWithResume(worktree, resultPath, writable, schemaPath, r.config.CodexModel, resumeThreadID)
	cmd := exec.CommandContext(ctx, r.config.CodexBin, args...)
	cmd.Stdin = strings.NewReader(prompt)
	cmd.Env = r.agentEnvironment()
	var stdout limitedBuffer
	var stderr limitedBuffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	err := cmd.Run()
	messageBytes, readErr := os.ReadFile(resultPath)
	_ = os.Remove(resultPath)
	threadID := codexThreadID(stdout.Bytes())
	if threadID == "" {
		threadID = resumeThreadID
	}
	if err != nil {
		return strings.TrimSpace(string(messageBytes)), threadID, fmt.Errorf("codex exec failed: %w: %s", err, stderr.String())
	}
	if readErr != nil {
		return "", threadID, fmt.Errorf("read Codex result: %w", readErr)
	}
	return strings.TrimSpace(string(messageBytes)), threadID, nil
}

func codexExecArgs(job Job, worktree, resultPath string, writable bool, schemaPath, model string) []string {
	resumeThreadID := ""
	if job.Mode == JobModeAnalyze {
		resumeThreadID = job.ResumeThreadID
	}
	return codexExecArgsWithResume(worktree, resultPath, writable, schemaPath, model, resumeThreadID)
}

func codexExecArgsWithResume(worktree, resultPath string, writable bool, schemaPath, model, resumeThreadID string) []string {
	args := []string{
		"exec", "--json", "--ignore-user-config",
		"-c", `approval_policy="never"`,
		"-c", `sandbox_workspace_write.network_access=true`,
		"--sandbox",
	}
	if writable {
		args = append(args, "workspace-write")
	} else {
		args = append(args, "read-only")
	}
	if schemaPath != "" {
		args = append(args, "--output-schema", schemaPath)
	}
	args = append(args, "-C", worktree, "-o", resultPath)
	if resumeThreadID != "" {
		args = append(args, "resume", resumeThreadID)
	}
	args = append(args, "-")
	if model != "" {
		args = append(args[:1], append([]string{"--model", model}, args[1:]...)...)
	}
	return args
}

func (r *CodexRunner) ensureRepository(ctx context.Context) error {
	if _, err := os.Stat(filepath.Join(r.config.RepoPath, ".git")); err == nil {
		_, err = r.command(ctx, r.config.RepoPath, r.config.GitHubBin, "auth", "setup-git")
		return err
	} else if !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("inspect target repository: %w", err)
	}
	if err := os.MkdirAll(filepath.Dir(r.config.RepoPath), 0o750); err != nil {
		return err
	}
	if _, err := r.command(ctx, filepath.Dir(r.config.RepoPath), r.config.GitHubBin, "auth", "setup-git"); err != nil {
		return fmt.Errorf("configure GitHub git authentication: %w", err)
	}
	if _, err := r.command(ctx, filepath.Dir(r.config.RepoPath), r.config.GitHubBin, "repo", "clone", r.config.GitHubRepo, r.config.RepoPath); err != nil {
		return fmt.Errorf("clone target repository: %w", err)
	}
	return nil
}

const remoteMainRef = "refs/remotes/origin/main"

func freshMainFetchArgs() []string {
	return []string{"fetch", "--no-tags", "origin", "+refs/heads/main:" + remoteMainRef}
}

func (r *CodexRunner) fetchFreshMain(ctx context.Context) (string, error) {
	if err := r.git(ctx, r.config.RepoPath, freshMainFetchArgs()...); err != nil {
		return "", err
	}
	baseSHA, err := r.command(ctx, r.config.RepoPath, "git", "rev-parse", "--verify", remoteMainRef)
	if err != nil {
		return "", fmt.Errorf("resolve freshly fetched origin/main: %w", err)
	}
	baseSHA = strings.TrimSpace(baseSHA)
	if baseSHA == "" {
		return "", errors.New("freshly fetched origin/main has no commit SHA")
	}
	return baseSHA, nil
}

func (r *CodexRunner) verifyWorktreeBase(ctx context.Context, worktree, expectedSHA string) error {
	actualSHA, err := r.command(ctx, worktree, "git", "rev-parse", "--verify", "HEAD")
	if err != nil {
		return fmt.Errorf("resolve task worktree base: %w", err)
	}
	if strings.TrimSpace(actualSHA) != expectedSHA {
		return fmt.Errorf("task worktree base %s does not match freshly fetched origin/main %s", strings.TrimSpace(actualSHA), expectedSHA)
	}
	return nil
}

func (r *CodexRunner) agentEnvironment() []string {
	allowed := map[string]struct{}{
		"PATH": {}, "LANG": {}, "LC_ALL": {}, "SSL_CERT_FILE": {}, "SSL_CERT_DIR": {},
	}
	for _, name := range r.config.AgentEnvAllowlist {
		allowed[name] = struct{}{}
	}
	forbidden := map[string]struct{}{
		"GH_TOKEN": {}, "GITHUB_TOKEN": {},
		"HOME": {}, "CODEX_HOME": {},
		"AUTO_ONCALL_OPTIMIZER_LARK_APP_SECRET": {}, "AUTO_ONCALL_OPTIMIZER_LARK_ENCRYPT_KEY": {},
		"AUTO_ONCALL_OPTIMIZER_LARK_VERIFICATION_TOKEN": {}, "AUTO_ONCALL_OPTIMIZER_ADMIN_TOKEN": {},
	}
	env := make([]string, 0, len(allowed)+1)
	for name := range allowed {
		if _, blocked := forbidden[name]; blocked {
			continue
		}
		if value, ok := os.LookupEnv(name); ok {
			env = append(env, name+"="+value)
		}
	}
	if r.config.CodexHome != "" {
		env = append(env, "CODEX_HOME="+r.config.CodexHome)
	}
	agentHome := filepath.Join(r.config.WorkspaceRoot, "agent-home")
	_ = os.MkdirAll(agentHome, 0o700)
	env = append(env, "HOME="+agentHome)
	sort.Strings(env)
	return env
}

func agentPrompt(job Job) string {
	mode := "Read-only diagnosis. Do not modify files, create branches, commit, push, open PRs, or write to external systems."
	if job.Mode == JobModePR {
		mode = "Implement the requested fix only inside auto-oncall-services/. Run relevant tests. Do not commit, push, create or merge a PR; the orchestrator owns those side effects."
	}
	return fmt.Sprintf(`You are the engineering agent for the tidbcloud/aisupport repository.
Your sole target is auto-oncall-services. Read and obey every applicable AGENTS.md completely.
Read and follow $CODEX_HOME/skills/runtime-evidence/SKILL.md for every task. Start from real runtime or persisted evidence when the request concerns current behavior. Never substitute repository templates for deployed state. Use installed provider skills when applicable.
%s
If the change affects auto-oncall analysis quality, maintain the required dedicated docs/opts optimization record.
Keep the requested scope tight. End with a concise Chinese summary containing evidence, changed files, tests, and an 信息来源与缺口 section. Explicitly name every decisive source that was unavailable and classify the conclusion as 已验证 or 基于有限证据推断.

Request from Feishu user %s:
%s`, mode, job.SenderOpenID, job.Request)
}

func codexThreadID(data []byte) string {
	scanner := bufio.NewScanner(bytes.NewReader(data))
	for scanner.Scan() {
		var event map[string]any
		if json.Unmarshal(scanner.Bytes(), &event) != nil {
			continue
		}
		if event["type"] == "thread.started" {
			if id, ok := event["thread_id"].(string); ok {
				return id
			}
		}
	}
	return ""
}

func changedPaths(ctx context.Context, worktree string) ([]string, error) {
	tracked, err := commandOutput(ctx, worktree, "git", "diff", "--name-only", "HEAD")
	if err != nil {
		return nil, err
	}
	untracked, err := commandOutput(ctx, worktree, "git", "ls-files", "--others", "--exclude-standard")
	if err != nil {
		return nil, err
	}
	unique := map[string]struct{}{}
	for _, line := range strings.Split(tracked+"\n"+untracked, "\n") {
		if line = strings.TrimSpace(line); line != "" {
			unique[filepath.ToSlash(line)] = struct{}{}
		}
	}
	paths := make([]string, 0, len(unique))
	for path := range unique {
		paths = append(paths, path)
	}
	sort.Strings(paths)
	return paths, nil
}

func validateChangedPaths(paths []string) error {
	for _, path := range paths {
		path = filepath.ToSlash(filepath.Clean(path))
		if path != "auto-oncall-services" && !strings.HasPrefix(path, "auto-oncall-services/") {
			return fmt.Errorf("change outside auto-oncall-services is not allowed: %s", path)
		}
	}
	return nil
}

func validateValidationRepairPaths(before, after []string) error {
	if err := validateChangedPaths(after); err != nil {
		return err
	}
	beforeSet := make(map[string]struct{}, len(before))
	for _, path := range before {
		clean := filepath.ToSlash(filepath.Clean(path))
		beforeSet[clean] = struct{}{}
	}
	afterSet := make(map[string]struct{}, len(after))
	for _, path := range after {
		clean := filepath.ToSlash(filepath.Clean(path))
		afterSet[clean] = struct{}{}
		if _, existed := beforeSet[clean]; !existed && !strings.HasPrefix(clean, "auto-oncall-services/test/") {
			return fmt.Errorf("single-ticket validation repair added a non-test path: %s", clean)
		}
	}
	for path := range beforeSet {
		if _, retained := afterSet[path]; !retained {
			return fmt.Errorf("single-ticket validation repair removed an admitted path: %s", path)
		}
	}
	return nil
}

func validateInvestigationPaths(paths []string) error {
	if len(paths) != 1 || paths[0] != singleTicketContextFile {
		return fmt.Errorf("single-ticket investigator changed task worktree: %v", paths)
	}
	return nil
}

func (r *CodexRunner) removeWorktree(ctx context.Context, path, branch string) error {
	if err := r.git(ctx, r.config.RepoPath, "worktree", "remove", path); err != nil {
		return fmt.Errorf("remove completed worktree: %w", err)
	}
	if branch != "" {
		if err := r.git(ctx, r.config.RepoPath, "branch", "-D", branch); err != nil {
			return fmt.Errorf("delete completed local branch: %w", err)
		}
	}
	if err := r.git(ctx, r.config.RepoPath, "worktree", "prune"); err != nil {
		return fmt.Errorf("prune worktrees: %w", err)
	}
	return nil
}

func (r *CodexRunner) git(ctx context.Context, dir string, args ...string) error {
	_, err := r.command(ctx, dir, "git", args...)
	return err
}

func (r *CodexRunner) command(ctx context.Context, dir, name string, args ...string) (string, error) {
	return commandOutput(ctx, dir, name, args...)
}

func commandOutput(ctx context.Context, dir, name string, args ...string) (string, error) {
	return commandOutputWithEnv(ctx, dir, nil, name, args...)
}

func commandOutputWithEnv(ctx context.Context, dir string, env []string, name string, args ...string) (string, error) {
	cmd := exec.CommandContext(ctx, name, args...)
	cmd.Dir = dir
	if env != nil {
		cmd.Env = env
	}
	var stdout limitedTailBuffer
	var stderr limitedTailBuffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return stdout.String(), fmt.Errorf("%s %s: %w\nstdout:\n%s\nstderr:\n%s", name, strings.Join(args, " "), err, stdout.String(), stderr.String())
	}
	return stdout.String(), nil
}

func prTitle(request string) string {
	title := strings.TrimSpace(strings.Split(request, "\n")[0])
	if len([]rune(title)) > 70 {
		title = string([]rune(title)[:70])
	}
	return "fix(auto-oncall): " + title
}

func prBody(job Job, result string) string {
	return fmt.Sprintf("Created by Auto Oncall Optimizer from Feishu job `%s`.\n\nRequest:\n\n%s\n\nAgent result:\n\n%s", job.ID, truncate(job.Request, 5000), truncate(result, 10000))
}

type limitedBuffer struct {
	buf bytes.Buffer
}

const maxCommandOutput = 128 * 1024

func (b *limitedBuffer) Write(value []byte) (int, error) {
	original := len(value)
	remaining := maxCommandOutput - b.buf.Len()
	if remaining > 0 {
		_, _ = b.buf.Write(value[:min(len(value), remaining)])
	}
	return original, nil
}

func (b *limitedBuffer) String() string { return b.buf.String() }
func (b *limitedBuffer) Bytes() []byte  { return b.buf.Bytes() }

var _ io.Writer = (*limitedBuffer)(nil)

type limitedTailBuffer struct {
	buf bytes.Buffer
}

func (b *limitedTailBuffer) Write(value []byte) (int, error) {
	original := len(value)
	_, _ = b.buf.Write(value)
	if overflow := b.buf.Len() - maxCommandOutput; overflow > 0 {
		_ = b.buf.Next(overflow)
	}
	return original, nil
}

func (b *limitedTailBuffer) String() string { return b.buf.String() }

func tailText(value string, limit int) string {
	runes := []rune(strings.TrimSpace(value))
	if len(runes) <= limit {
		return string(runes)
	}
	return "…" + string(runes[len(runes)-limit:])
}

var _ io.Writer = (*limitedTailBuffer)(nil)
