package agent

import (
	"context"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"runtime/debug"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/open-code-review/open-code-review/internal/config/rules"
	"github.com/open-code-review/open-code-review/internal/config/template"
	"github.com/open-code-review/open-code-review/internal/config/toolsconfig"
	"github.com/open-code-review/open-code-review/internal/diff"
	"github.com/open-code-review/open-code-review/internal/gitcmd"
	"github.com/open-code-review/open-code-review/internal/llm"
	"github.com/open-code-review/open-code-review/internal/llmloop"
	"github.com/open-code-review/open-code-review/internal/model"
	"github.com/open-code-review/open-code-review/internal/session"
	"github.com/open-code-review/open-code-review/internal/stdout"
	"github.com/open-code-review/open-code-review/internal/telemetry"
	"github.com/open-code-review/open-code-review/internal/tool"

	"go.opentelemetry.io/otel/codes"
)

// AgentWarning is re-exported from llmloop for backwards compatibility with
// existing callers (cmd/opencodereview/output.go).
type AgentWarning = llmloop.AgentWarning

// CommentWorkerPool is re-exported from llmloop for backwards compatibility.
type CommentWorkerPool = llmloop.CommentWorkerPool

// NewCommentWorkerPool delegates to llmloop.NewCommentWorkerPool.
func NewCommentWorkerPool(workerCount int) *CommentWorkerPool {
	return llmloop.NewCommentWorkerPool(workerCount)
}

// Args holds all dependencies and configuration needed to run a review session.
type Args struct {
	// RepoDir is the root of the git repository.
	RepoDir string

	// From and To define the diff range (e.g., "main..feature-branch").
	From string
	To   string

	// Commit is a single commit hash to review (vs its parent).
	Commit string

	// ReviewMode is one of "workspace", "range", or "commit".
	// When empty, it is derived from From/To/Commit at session creation time.
	// Full-scan reviews are owned by internal/scan and never reach this Args.
	ReviewMode string

	// Template loaded from YAML config file.
	Template template.Template

	// SystemRule holds path-based review rules loaded from a JSON config.
	SystemRule rules.Resolver

	// FileFilter holds user-configured include/exclude path patterns from rule.json.
	// When nil, only the default extension and path filters apply.
	FileFilter *rules.FileFilter

	// LLM client for model inference.
	LLMClient llm.LLMClient

	// Tool registry mapping tool aliases to implementations.
	Tools *tool.Registry

	// PlanToolDefs holds llm.ToolDef entries enabled in plan_task, built once at startup.
	// When nil, plan phase sends no tool definitions (same as Java behavior when plan_task is false).
	PlanToolDefs []llm.ToolDef

	// MainToolDefs holds llm.ToolDef entries enabled in main_task, built once at startup.
	MainToolDefs []llm.ToolDef

	// CommentWorkerPool — separate goroutine pool for running asynchronous
	// comment post-processing tasks (tracking, re-tracking, reflection,
	// suggestion validation). This mirrors the Java side's subtaskExecutor
	// which executes the CODE_COMMENT tool off the critical path so that the
	// main LLM tool-use loop can continue issuing requests while comments are
	// being processed in the background.
	//
	// When nil (the default), comment processing happens synchronously inside
	// executeToolCall instead of via a separate worker pool.
	CommentWorkerPool *CommentWorkerPool

	// Concurrency limit for per-file subtasks. Defaults to number of CPUs.
	MaxConcurrency int

	// Concurrent task timeout in minutes. 0 means no timeout.
	ConcurrentTaskTimeout int

	// CommentCollector collects review comments generated by the code_comment tool.
	CommentCollector *tool.CommentCollector

	// Background is an optional requirement/business context string
	// injected into plan and main_task prompts via {{requirement_background}}.
	Background string

	// Model is the user-configured model name used as fallback when
	// template phases (plan/memory_compression) don't specify one.
	Model string

	// GitRunner limits the total number of concurrent git subprocesses.
	// When nil, subprocesses are spawned without a global limit.
	GitRunner *gitcmd.Runner

	// Session is an optional session history instance for collecting conversation records.
	// When nil, a default one is created automatically with git branch auto-detected from repoDir.
	Session *session.SessionHistory

	// Resume is an optional read-only checkpoint index from a previous review session.
	Resume *session.ResumeState
}

// Agent orchestrates the AI-powered code review. LLM tool-use loop / memory
// compression / token aggregation now live in internal/llmloop.Runner; this
// struct holds the diff-side state and orchestrates per-file subtasks.
type Agent struct {
	args            Args
	diffs           []model.Diff // parsed diffs
	totalInsertions int64
	totalDeletions  int64
	currentDate     string
	session         *session.SessionHistory
	subtaskFailed   int64 // count of failed subtasks, accessed atomically
	runner          *llmloop.Runner
	resumeInfo      *ResumeInfo
}

// ResumeInfo summarizes file-level reuse for a resumed review.
type ResumeInfo struct {
	ResumedFrom   string `json:"resumed_from"`
	ReusedFiles   int64  `json:"reused_files"`
	RerunFiles    int64  `json:"rerun_files"`
	PreviousModel string `json:"previous_model,omitempty"`
	CurrentModel  string `json:"current_model,omitempty"`
}

// New creates a new Agent from the given arguments.
func New(args Args) *Agent {
	if args.Tools == nil {
		args.Tools = tool.NewRegistry()
	}
	if args.CommentCollector == nil {
		args.CommentCollector = tool.NewCommentCollector()
	}
	if args.Session == nil {
		gitBranch := detectGitBranch(context.Background(), args.RepoDir)
		mode := args.ReviewMode
		if mode == "" {
			mode = reviewModeString(args.From, args.To, args.Commit)
		}
		args.Session = session.New(args.RepoDir, gitBranch, args.Model, session.SessionOptions{
			ReviewMode:  mode,
			DiffFrom:    args.From,
			DiffTo:      args.To,
			DiffCommit:  args.Commit,
			ResumedFrom: resumedFromSession(args.Resume),
		})
	}
	a := &Agent{
		args:    args,
		session: args.Session,
	}
	// DiffLookup closure captures a so the runner can resolve per-file
	// model.Diff records lazily (a.diffs is only populated by loadDiffs,
	// after New returns).
	a.runner = llmloop.NewRunner(llmloop.Deps{
		LLMClient:         args.LLMClient,
		Model:             args.Model,
		Template:          args.Template,
		Tools:             args.Tools,
		MainToolDefs:      args.MainToolDefs,
		CommentCollector:  args.CommentCollector,
		CommentWorkerPool: args.CommentWorkerPool,
		Session:           args.Session,
		DiffLookup:        a.findDiff,
	})
	return a
}

// Run executes the full review pipeline: parse diffs -> plan per file -> LLM tool-loop -> collect comments.
func (a *Agent) Run(ctx context.Context) ([]model.LlmComment, error) {
	// Step 1: Parse diffs
	ctx, diffSpan := telemetry.StartSpan(ctx, "diff.parse")
	if err := a.loadDiffs(ctx); err != nil {
		diffSpan.End()
		return nil, fmt.Errorf("load diffs: %w", err)
	}
	telemetry.SetAttr(diffSpan, "files.changed", len(a.diffs))
	telemetry.SetAttr(diffSpan, "lines.inserted", int64(a.totalInsertions))
	telemetry.SetAttr(diffSpan, "lines.deleted", int64(a.totalDeletions))
	diffSpan.End()

	// Build the read-only DiffMap from ALL parsed diffs (before filtering)
	// so the LLM can query diffs of related but filtered-out files.
	a.injectDiffMap()
	a.args.Tools.Freeze()

	totalChanged := len(a.diffs)
	reviewCount := a.countReviewable(a.diffs)
	fmt.Fprintf(stdout.Writer(), "[ocr] %d file(s) changed, reviewing %d in %s\n", totalChanged, reviewCount, a.args.RepoDir)

	a.diffs = a.filterDiffs(a.diffs)

	if len(a.diffs) == 0 {
		fmt.Fprintln(stdout.Writer(), "[ocr] No supported files changed. Skipping review.")
		telemetry.Event(ctx, "no.files.changed")
		a.session.Finalize()
		return []model.LlmComment{}, nil
	}

	a.currentDate = time.Now().Format("2006-01-02 15:04")
	telemetry.Event(ctx, "review.started",
		telemetry.AnyToAttr("file.count", totalChanged),
		telemetry.AnyToAttr("review.count", reviewCount),
		telemetry.AnyToAttr("repo.dir", a.args.RepoDir))

	// Record file count metric.
	telemetry.RecordFilesReviewed(ctx, int64(reviewCount))

	// Step 2: Dispatch per-file subtasks concurrently
	comments, err := a.dispatchSubtasks(ctx)
	if len(comments) > 0 {
		telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
	}
	a.session.Finalize()
	return comments, err
}

// Session returns the session history associated with this Agent.
func (a *Agent) Session() *session.SessionHistory {
	return a.session
}

// SessionID returns the current review's session id, or "" when no session has been created.
func (a *Agent) SessionID() string {
	if a == nil || a.session == nil {
		return ""
	}
	return a.session.SessionID
}

// ResumeInfo returns resume metadata for output. Nil means this was not a resume run.
func (a *Agent) ResumeInfo() *ResumeInfo {
	if a.resumeInfo == nil {
		return nil
	}
	info := *a.resumeInfo
	return &info
}

// FilesReviewed returns the number of changed files included in this review.
func (a *Agent) FilesReviewed() int64 {
	return int64(len(a.diffs))
}

// Diffs returns the parsed diffs loaded by the agent.
func (a *Agent) Diffs() []model.Diff {
	return a.diffs
}

// TotalTokensUsed returns PromptTokens + CompletionTokens across all LLM calls.
// For Anthropic, PromptTokens already includes cache read/write tokens.
func (a *Agent) TotalTokensUsed() int64 { return a.runner.TotalTokensUsed() }

// TotalInputTokens returns the accumulated input/prompt tokens from all LLM calls.
func (a *Agent) TotalInputTokens() int64 { return a.runner.TotalInputTokens() }

// TotalOutputTokens returns the accumulated completion tokens from all LLM calls.
func (a *Agent) TotalOutputTokens() int64 { return a.runner.TotalOutputTokens() }

// TotalCacheReadTokens returns the accumulated cache read tokens from all LLM calls.
func (a *Agent) TotalCacheReadTokens() int64 { return a.runner.TotalCacheReadTokens() }

// TotalCacheWriteTokens returns the accumulated cache write tokens from all LLM calls.
func (a *Agent) TotalCacheWriteTokens() int64 { return a.runner.TotalCacheWriteTokens() }

// ProjectSummary returns the markdown project-level summary. Always empty
// for the diff-review path; defined so *Agent satisfies the
// cmd/opencodereview.ResultProvider interface that scan.Agent also implements.
func (a *Agent) ProjectSummary() string { return "" }

// Warnings returns a copy of non-fatal warnings recorded during review.
func (a *Agent) Warnings() []AgentWarning { return a.runner.Warnings() }

// ToolCalls returns per-tool call counts accumulated during review.
func (a *Agent) ToolCalls() map[string]int64 { return a.runner.ToolCalls() }

// recordWarning adds a non-fatal warning to the agent's warning list.
func (a *Agent) recordWarning(warningType, file, message string) {
	a.runner.RecordWarning(warningType, file, message)
}

// loadDiffs populates the diff-related fields.
func (a *Agent) loadDiffs(ctx context.Context) error {
	var provider *diff.Provider

	switch {
	case a.args.Commit != "":
		provider = diff.NewCommitProvider(a.args.RepoDir, a.args.Commit, a.args.GitRunner)
	case a.args.From != "" && a.args.To != "":
		provider = diff.NewProvider(a.args.RepoDir, a.args.From, a.args.To, a.args.GitRunner)
	default:
		provider = diff.NewWorkspaceProvider(a.args.RepoDir, a.args.GitRunner)
	}

	parsed, err := provider.GetDiff(ctx)
	if err != nil {
		return fmt.Errorf("get diffs: %w", err)
	}

	a.diffs = parsed

	for i := range parsed {
		d := &parsed[i]
		a.totalInsertions += d.Insertions
		a.totalDeletions += d.Deletions
	}

	return nil
}

// injectDiffMap builds a read-only DiffMap from parsed diffs and sets it
// on the FileReadDiffProvider. Must be called after loadDiffs and before
// any concurrent access to the registry.
func (a *Agent) injectDiffMap() {
	m := make(map[string]string, len(a.diffs))
	for i := range a.diffs {
		d := &a.diffs[i]
		if d.NewPath != "/dev/null" {
			m[d.NewPath] = d.Diff
		}
	}
	dm := tool.NewDiffMap(m)
	if p, ok := a.args.Tools.Get(tool.FileReadDiff.Name()); ok {
		if frd, ok := p.(*tool.FileReadDiffProvider); ok {
			frd.SetDiffMap(dm)
		}
	}
}

// dispatchSubtasks runs the Plan + Main phases for each changed file concurrently.
func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error) {
	startTime := time.Now()
	defer func() {
		telemetry.RecordReviewDuration(ctx, time.Since(startTime))
	}()

	// Pre-filter: discard diffs whose diff content alone exceeds 80% of the token threshold.
	a.diffs = a.filterLargeDiffs(a.diffs)
	if len(a.diffs) == 0 {
		return nil, fmt.Errorf("all diffs filtered out by token size")
	}
	toDispatch := a.applyResume(a.diffs)

	var wg sync.WaitGroup

	concurrency := a.args.MaxConcurrency
	if concurrency <= 0 {
		concurrency = 8
	}

	sem := make(chan struct{}, concurrency)
	timeout := time.Duration(a.args.ConcurrentTaskTimeout) * time.Minute

	var dispatched int64
	for i := range toDispatch {
		if toDispatch[i].IsDeleted {
			continue
		}
		dispatched++
		wg.Add(1)
		sem <- struct{}{} // acquire semaphore

		go func(d model.Diff) {
			fingerprint := reviewItemFingerprint(a.reviewMode(), d)
			defer wg.Done()
			defer func() { <-sem }() // release
			// A panic while reviewing one file must be isolated exactly like an
			// error return: counted in subtaskFailed and recorded as a
			// subtask_error warning, so other files still complete and the
			// all-failed rollup below stays correct. Registered before the
			// timeout-cancel defer, so cancel() still runs first on unwind and
			// fileCtx is already cancelled here — use the parent ctx for telemetry.
			defer func() {
				if r := recover(); r != nil {
					atomic.AddInt64(&a.subtaskFailed, 1)
					a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, fmt.Sprintf("panic: %v", r))
					fmt.Fprintf(stdout.Writer(), "[ocr] Subtask panic for %s: %v\n%s\n", d.NewPath, r, debug.Stack())
					telemetry.ErrorEvent(ctx, "subtask.panic", fmt.Errorf("panic: %v", r),
						telemetry.AnyToAttr("file.path", d.NewPath))
					a.recordWarning("subtask_error", d.NewPath, fmt.Sprintf("panic: %v", r))
				}
			}()

			var fileCtx context.Context
			var cancel context.CancelFunc
			if timeout > 0 {
				fileCtx, cancel = context.WithTimeout(ctx, timeout)
				defer cancel()
			} else {
				fileCtx = ctx
			}

			completed, skipReason, err := a.executeSubtask(fileCtx, d)
			if err != nil {
				atomic.AddInt64(&a.subtaskFailed, 1)
				a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, err.Error())
				fmt.Fprintf(stdout.Writer(), "[ocr] Subtask error for %s: %v\n", d.NewPath, err)
				telemetry.ErrorEvent(fileCtx, "subtask.error", err,
					telemetry.AnyToAttr("file.path", d.NewPath))
				a.recordWarning("subtask_error", d.NewPath, err.Error())
				return
			}
			if !completed {
				if skipReason != "" {
					a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, skipReason)
				}
				return
			}
			comments := a.args.CommentCollector.CommentsForPath(d.NewPath)
			a.session.RecordReviewItemDone(d.NewPath, d.OldPath, d.NewPath, fingerprint, comments)
		}(toDispatch[i])
	}

	wg.Wait()

	if dispatched == 0 {
		return a.args.CommentCollector.Comments(), nil
	}

	// All subtasks finished — collect comments from the global collector once.
	if a.args.CommentWorkerPool != nil {
		a.args.CommentWorkerPool.Await()
	}

	failed := atomic.LoadInt64(&a.subtaskFailed)
	if failed > 0 && failed == dispatched {
		return nil, fmt.Errorf("all %d file review(s) failed — check your LLM configuration and API key", dispatched)
	}

	return a.args.CommentCollector.Comments(), nil
}

func (a *Agent) applyResume(diffs []model.Diff) []model.Diff {
	resume := a.args.Resume
	if resume == nil {
		return diffs
	}

	mode := a.reviewMode()
	toDispatch := make([]model.Diff, 0, len(diffs))
	var reused int64
	for _, d := range diffs {
		if d.IsDeleted {
			toDispatch = append(toDispatch, d)
			continue
		}
		fingerprint := reviewItemFingerprint(mode, d)
		item, ok := resume.Item(fingerprint)
		if !ok {
			toDispatch = append(toDispatch, d)
			continue
		}
		for _, cm := range item.Comments {
			a.args.CommentCollector.Add(cm)
		}
		a.session.RecordReviewItemReused(effectivePath(d), d.OldPath, d.NewPath, fingerprint, resume.SessionID, item.Comments)
		reused++
	}

	rerun := countDispatchable(toDispatch)
	a.resumeInfo = &ResumeInfo{
		ResumedFrom:   resume.SessionID,
		ReusedFiles:   reused,
		RerunFiles:    rerun,
		PreviousModel: resume.Model,
		CurrentModel:  a.args.Model,
	}
	fmt.Fprintf(stdout.Writer(), "[ocr] Resume %s: reusing %d file(s), reviewing %d file(s)\n", resume.SessionID, reused, rerun)
	return toDispatch
}

func countDispatchable(diffs []model.Diff) int64 {
	var n int64
	for _, d := range diffs {
		if !d.IsDeleted {
			n++
		}
	}
	return n
}

func (a *Agent) reviewMode() string {
	if a.args.ReviewMode != "" {
		return a.args.ReviewMode
	}
	return reviewModeString(a.args.From, a.args.To, a.args.Commit)
}

func reviewItemFingerprint(mode string, d model.Diff) string {
	sum := sha256.Sum256([]byte(mode + "\x00" + d.OldPath + "\x00" + d.NewPath + "\x00" + d.Diff))
	return fmt.Sprintf("%x", sum)
}

func resumedFromSession(resume *session.ResumeState) string {
	if resume == nil {
		return ""
	}
	return resume.SessionID
}

// executeSubtask performs the Plan Phase + Main Loop for a single file.
func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) (bool, string, error) {
	ctx, span := telemetry.StartSpan(ctx, "subtask.execute."+d.NewPath)
	defer span.End()
	telemetry.SetAttr(span, "file.path", d.NewPath)
	telemetry.SetAttr(span, "lines.changed", d.Insertions+d.Deletions)
	telemetry.SetAttr(span, "lines.inserted", d.Insertions)
	telemetry.SetAttr(span, "lines.deleted", d.Deletions)

	if ctx.Err() != nil {
		return false, "", ctx.Err()
	}

	newPath := d.NewPath

	// Build change-files list excluding current file
	changeFilesExcludingCurrent := a.buildChangeFilesExcept(newPath)

	rule := a.resolveSystemRule(strings.ToLower(newPath))

	threshold := a.args.Template.PlanModeLineThreshold
	changeLines := d.Insertions + d.Deletions

	// Phase 1: Plan (skip when changes are below threshold)
	var planResult string
	if a.args.Template.PlanTask != nil && len(a.args.Template.PlanTask.Messages) > 0 && threshold > 0 && changeLines < int64(threshold) {
		fmt.Fprintf(stdout.Writer(), "[ocr] Skipping plan phase for %s (%d lines < threshold %d)\n", newPath, changeLines, threshold)
		telemetry.Event(ctx, "plan.skipped",
			telemetry.AnyToAttr("file.path", newPath),
			telemetry.AnyToAttr("lines.changed", changeLines),
			telemetry.AnyToAttr("threshold", threshold))
	} else if a.args.Template.PlanTask != nil && len(a.args.Template.PlanTask.Messages) > 0 {
		var err error
		planResult, err = a.executePlanPhase(ctx, newPath, d.Diff, changeFilesExcludingCurrent, rule)
		if err != nil {
			fmt.Fprintf(stdout.Writer(), "[ocr] Plan phase failed for %s: %v (continuing without plan)\n", newPath, err)
			telemetry.Eventf(ctx, "plan.failed", err.Error(),
				telemetry.AnyToAttr("file.path", newPath))
			planResult = ""
		}
	}

	// Phase 2: Main task loop
	if len(a.args.Template.MainTask.Messages) == 0 {
		return false, "", fmt.Errorf("main_task.messages is empty in template")
	}

	rawMsgs := a.args.Template.MainTask.Messages
	messages := make([]llm.Message, 0, len(rawMsgs))
	for _, m := range rawMsgs {
		content := m.Content
		content = strings.ReplaceAll(content, "{{current_system_date_time}}", a.currentDate)
		content = strings.ReplaceAll(content, "{{current_file_path}}", newPath)
		content = strings.ReplaceAll(content, "{{system_rule}}", rule)
		content = strings.ReplaceAll(content, "{{change_files}}", changeFilesExcludingCurrent)
		content = strings.ReplaceAll(content, "{{diff}}", d.Diff)
		content = strings.ReplaceAll(content, "{{requirement_background}}", a.args.Background)
		// Always substitute the {{plan_guidance}} token so the literal placeholder
		// never leaks into the rendered prompt. When the plan phase produced no
		// output, strip the surrounding "### Review Plan (Optional)\n…\n\n" wrapper
		// (any language variant) so the LLM does not see a dangling section header.
		// Strip MUST run before ReplaceAll: the regex requires the literal
		// {{plan_guidance}} token to be present; if we replace first, the token
		// is gone and the wrapper can't be matched.
		if planResult == "" {
			content = stripEmptyPlanBlock(content)
		}
		content = strings.ReplaceAll(content, "{{plan_guidance}}", planResult)
		messages = append(messages, llm.NewTextMessage(m.Role, content))
	}

	tokenCount := llmloop.CountMessagesTokens(messages)
	maxAllowed := a.args.Template.MaxTokens
	tokenLimit := maxAllowed * 4 / 5 // 80% of MaxTokens
	if tokenCount > tokenLimit {
		msg := fmt.Sprintf("prompt tokens (%d) exceed %d%% of max_tokens(%d)", tokenCount, 80, maxAllowed)
		fmt.Fprintf(stdout.Writer(), "[ocr] WARNING: %s for %s\n", msg, newPath)
		a.recordWarning("token_threshold_exceeded", newPath, msg)
		telemetry.Event(ctx, "token.threshold.exceeded",
			telemetry.AnyToAttr("file.path", newPath),
			telemetry.AnyToAttr("tokens", tokenCount),
			telemetry.AnyToAttr("max_tokens", maxAllowed))
		return false, msg, nil
	}

	mainCompleted, err := func() (bool, error) {
		ctx, mainSpan := telemetry.StartSpan(ctx, "main.loop")
		defer mainSpan.End()
		telemetry.SetAttr(mainSpan, "file.path", newPath)
		completed, err := a.runner.RunPerFile(ctx, messages, newPath)
		if err != nil {
			mainSpan.SetStatus(codes.Error, err.Error())
			mainSpan.RecordError(err)
			return false, err
		}
		return completed, nil
	}()
	if err == nil {
		// REVIEW_FILTER_TASK runs after the main loop and decides which of the
		// just-collected comments to drop. It needs to see comments produced by
		// the async CommentWorkerPool, so wait for that to drain first.
		if a.args.CommentWorkerPool != nil {
			a.args.CommentWorkerPool.Await()
		}
		a.executeReviewFilter(ctx, d, newPath)
	}
	if err != nil {
		return false, "", err
	}
	if !mainCompleted {
		return false, "main_task did not complete before stopping", nil
	}
	return true, "", nil
}

// executeReviewFilter runs the REVIEW_FILTER_TASK to remove comments that are
// provably incorrect based solely on the diff. Errors are logged and silently ignored.
func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath string) {
	ctx, span := telemetry.StartSpan(ctx, "review_filter.execute")
	defer span.End()
	telemetry.SetAttr(span, "file.path", newPath)

	ft := a.args.Template.ReviewFilterTask
	if ft == nil || len(ft.Messages) == 0 {
		return
	}

	comments := a.args.CommentCollector.CommentsForPath(newPath)
	if len(comments) == 0 {
		return
	}
	telemetry.SetAttr(span, "comments.before", len(comments))

	commentsJSON := buildFilterCommentsJSON(comments)

	messages := make([]llm.Message, 0, len(ft.Messages))
	for _, m := range ft.Messages {
		content := m.Content
		content = strings.ReplaceAll(content, "{{path}}", newPath)
		content = strings.ReplaceAll(content, "{{diff}}", d.Diff)
		content = strings.ReplaceAll(content, "{{comments}}", commentsJSON)
		messages = append(messages, llm.NewTextMessage(m.Role, content))
	}

	if ft.Timeout > 0 {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, time.Duration(ft.Timeout)*time.Second)
		defer cancel()
	}

	fs := a.session.GetOrCreateFileSession(newPath)
	rec := fs.AppendTaskRecord(session.ReviewFilterTask, messages)
	startTime := time.Now()

	_, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model)
	resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
		Model:     a.args.Model,
		Messages:  messages,
		MaxTokens: a.args.Template.MaxTokens,
	})
	duration := time.Since(startTime)
	if err != nil {
		telemetry.RecordLLMResult(llmSpan, duration, 0, err)
		llmSpan.End()
		rec.SetError(err, duration)
		fmt.Fprintf(stdout.Writer(), "[ocr] Review filter failed for %s: %v\n", newPath, err)
		span.SetStatus(codes.Error, err.Error())
		span.RecordError(err)
		return
	}
	var totalTokens int64
	if resp.Usage != nil {
		totalTokens = resp.Usage.TotalTokens
	}
	telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
	llmSpan.End()
	rec.SetResponse(resp, duration)
	a.runner.RecordUsage(resp.Usage)

	indices := parseFilterResponse(resp.Content(), len(comments))
	telemetry.SetAttr(span, "comments.filtered", len(indices))
	if len(indices) == 0 {
		return
	}

	a.args.CommentCollector.RemoveByPathAndIndices(newPath, indices)
	fmt.Fprintf(stdout.Writer(), "[ocr] Review filter removed %d comment(s) for %s\n", len(indices), newPath)
}

// buildFilterCommentsJSON serializes comments into a JSON array with generated IDs.
func buildFilterCommentsJSON(comments []model.LlmComment) string {
	type filterComment struct {
		ID           string `json:"id"`
		Content      string `json:"content"`
		ExistingCode string `json:"existing_code,omitempty"`
	}
	items := make([]filterComment, len(comments))
	for i, cm := range comments {
		items[i] = filterComment{
			ID:           fmt.Sprintf("c-%d", i),
			Content:      cm.Content,
			ExistingCode: cm.ExistingCode,
		}
	}
	data, _ := json.Marshal(items)
	return string(data)
}

// parseFilterResponse extracts comment indices from the LLM filter response.
// Returns a set of 0-based indices. Invalid IDs or out-of-range indices are ignored.
func parseFilterResponse(raw string, total int) map[int]struct{} {
	raw = llmloop.StripMarkdownFences(raw)
	var ids []string
	if err := json.Unmarshal([]byte(raw), &ids); err != nil {
		preview := raw
		if len(preview) > 200 {
			preview = preview[:200] + "..."
		}
		fmt.Fprintf(stdout.Writer(), "[ocr] Review filter: failed to parse LLM response: %v, raw: %s\n", err, preview)
		return nil
	}
	indices := make(map[int]struct{})
	for _, id := range ids {
		var idx int
		if _, err := fmt.Sscanf(id, "c-%d", &idx); err == nil && idx >= 0 && idx < total {
			indices[idx] = struct{}{}
		}
	}
	return indices
}

// buildChangeFilesExcept returns a formatted list of changed files except the given path.
func (a *Agent) buildChangeFilesExcept(excludePath string) string {
	var sb strings.Builder
	for i, d := range a.diffs {
		if d.IsBinary {
			continue
		}
		if d.NewPath == excludePath || d.OldPath == excludePath {
			continue
		}
		status := "MODIFIED"
		switch {
		case d.IsNew:
			status = "ADDED"
		case d.IsDeleted:
			status = "DELETED"
		case d.OldPath != d.NewPath:
			status = "RENAMED"
		}
		sb.WriteString(status + "   " + d.NewPath)
		if i < len(a.diffs)-1 {
			sb.WriteString("\n")
		}
	}
	return sb.String()
}

// resolveSystemRule returns the rule text for a given file path,
// matching against PathRuleMap glob patterns, falling back to DefaultRule.
func (a *Agent) resolveSystemRule(path string) string {
	if a.args.SystemRule == nil {
		return ""
	}
	return a.args.SystemRule.Resolve(path)
}

// filterLargeDiffs drops diffs whose diff content alone consumes more than 80% of MaxTokens.
func (a *Agent) filterLargeDiffs(diffs []model.Diff) []model.Diff {
	limit := a.args.Template.MaxTokens * 4 / 5
	if limit <= 0 {
		return diffs
	}
	var kept []model.Diff
	skipped := 0

	for _, d := range diffs {
		tokens := llm.CountTokens(d.Diff)
		if tokens > limit {
			fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s (~%d tokens exceeds 80%% of max_tokens(%d))\n",
				d.NewPath, tokens, a.args.Template.MaxTokens)
			skipped++
			continue
		}
		kept = append(kept, d)
	}

	if skipped > 0 {
		fmt.Fprintf(stdout.Writer(), "[ocr] Pre-filtered %d file(s) exceeding 80%% of max_tokens\n", skipped)
	}
	return kept
}

// countReviewable counts diffs that will survive all filters and are not pure deletions.
func (a *Agent) countReviewable(diffs []model.Diff) int {
	count := 0
	for _, d := range diffs {
		if !a.shouldReview(d) {
			continue
		}
		if d.IsDeleted {
			continue
		}
		count++
	}
	return count
}

// shouldReview applies the filter algorithm via whyExcluded.
func (a *Agent) shouldReview(d model.Diff) bool {
	return a.whyExcluded(d) == ExcludeNone
}

// filterDiffs drops diffs that should not be reviewed based on user-configured
// include/exclude patterns and default extension/path filters.
func (a *Agent) filterDiffs(diffs []model.Diff) []model.Diff {
	var kept []model.Diff
	skipped := 0

	for _, d := range diffs {
		path := effectivePath(d)
		if !a.shouldReview(d) {
			if d.IsBinary {
				fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s — binary file\n", path)
			} else {
				fmt.Fprintf(stdout.Writer(), "[ocr] Skipping %s — filtered by path/extension rules\n", path)
			}
			skipped++
			continue
		}
		kept = append(kept, d)
	}

	if skipped > 0 {
		fmt.Fprintf(stdout.Writer(), "[ocr] Filtered %d file(s) by include/exclude rules\n", skipped)
	}
	return kept
}

// extFromPath returns the file extension with leading dot, lowercased.
func (a *Agent) extFromPath(path string) string {
	basename := path
	if idx := strings.LastIndex(path, "/"); idx >= 0 {
		basename = path[idx+1:]
	}
	dot := strings.LastIndex(basename, ".")
	if dot <= 0 {
		return ""
	}
	return strings.ToLower(basename[dot:])
}

// executePlanPhase runs the plan task for a single file, sending template messages
// with resolved placeholders and collecting the LLM response as plan guidance.
func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFiles, rule string) (string, error) {
	ctx, span := telemetry.StartSpan(ctx, "plan.execute")
	defer span.End()
	telemetry.SetAttr(span, "file.path", newPath)

	pt := a.args.Template.PlanTask
	messages := make([]llm.Message, 0, len(pt.Messages))
	for _, m := range pt.Messages {
		content := m.Content
		content = strings.ReplaceAll(content, "{{current_system_date_time}}", a.currentDate)
		content = strings.ReplaceAll(content, "{{current_file_path}}", newPath)
		content = strings.ReplaceAll(content, "{{system_rule}}", rule)
		content = strings.ReplaceAll(content, "{{change_files}}", changeFiles)
		content = strings.ReplaceAll(content, "{{diff}}", rawDiff)
		content = strings.ReplaceAll(content, "{{requirement_background}}", a.args.Background)
		content = strings.ReplaceAll(content, "{{plan_tools}}", formatToolDefs(a.args.PlanToolDefs))
		messages = append(messages, llm.NewTextMessage(m.Role, content))
	}

	fs := a.session.GetOrCreateFileSession(newPath)
	rec := fs.AppendTaskRecord(session.PlanTask, messages)
	startTime := time.Now()

	_, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model)
	resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
		Model:     a.args.Model,
		Messages:  messages,
		MaxTokens: a.args.Template.MaxTokens,
	})
	duration := time.Since(startTime)
	if err != nil {
		telemetry.RecordLLMResult(llmSpan, duration, 0, err)
		llmSpan.End()
		rec.SetError(err, duration)
		span.SetStatus(codes.Error, err.Error())
		span.RecordError(err)
		return "", fmt.Errorf("plan request: %w", err)
	}
	var totalTokens int64
	if resp.Usage != nil {
		totalTokens = resp.Usage.TotalTokens
	}
	telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
	llmSpan.End()
	rec.SetResponse(resp, duration)
	a.runner.RecordUsage(resp.Usage)
	fmt.Fprintf(stdout.Writer(), "[ocr] Plan completed for %s\n", newPath)
	return resp.Content(), nil
}

// formatToolDefs renders tool definitions as human-readable text for embedding in prompts.
func formatToolDefs(toolDefs []llm.ToolDef) string {
	if len(toolDefs) == 0 {
		return ""
	}

	var sb strings.Builder
	sb.WriteString("### Available Tools (reference only — do not call)\n")
	for _, td := range toolDefs {
		fn := &td.Function
		sb.WriteString(fmt.Sprintf("- **%s**: %s\n", fn.Name, fn.Description))
		if params, ok := fn.Parameters["properties"].(map[string]any); ok && len(params) > 0 {
			sb.WriteString("  Parameters:\n")
			required := make(map[string]bool)
			if reqList, ok := fn.Parameters["required"].([]any); ok {
				for _, r := range reqList {
					if s, ok := r.(string); ok {
						required[s] = true
					}
				}
			}
			for name, p := range params {
				suffix := ""
				if required[name] {
					suffix = " (required)"
				}
				if pm, ok := p.(map[string]any); ok {
					desc, _ := pm["description"].(string)
					sb.WriteString(fmt.Sprintf("  - %s: %s%s\n", name, desc, suffix))
				} else {
					sb.WriteString(fmt.Sprintf("  - %s%s\n", name, suffix))
				}
			}
		}
	}
	return sb.String()
}

// findDiff returns the Diff for the given file path, or nil if not found.
func (a *Agent) findDiff(path string) *model.Diff {
	for i := range a.diffs {
		if a.diffs[i].NewPath == path || a.diffs[i].OldPath == path {
			return &a.diffs[i]
		}
	}
	return nil
}

// BuildToolDefs converts toolsconfig.ToolConfigEntry slice into []llm.ToolDef,
// filtering by phase (planOnly=true for plan_task, false for main_task).
func BuildToolDefs(entries []toolsconfig.ToolConfigEntry, planOnly bool) []llm.ToolDef {
	var defs []llm.ToolDef
	for _, e := range entries {
		defRaw, ok := e.ToolDefsByPhase(planOnly)
		if !ok {
			continue
		}
		var fn llm.FunctionDef
		if err := json.Unmarshal(defRaw, &fn); err != nil {
			fmt.Fprintf(stdout.Writer(), "[ocr] WARNING: failed to parse tool definition %q: %v\n", e.Name, err)
			continue
		}
		defs = append(defs, llm.ToolDef{
			Type:     "function",
			Function: fn,
		})
	}
	return defs
}
