package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/open-code-review/open-code-review/internal/agent"
	"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/model"
	"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"
)

// commonContext bundles the state that both `ocr review` and `ocr scan`
// need to load *before* deciding whether to dispatch a preview or a real
// LLM session: a validated template, the resolved repo path, review rules,
// and a shared git subprocess limiter.
type commonContext struct {
	Template   *template.Template
	RepoDir    string
	Resolver   rules.Resolver
	FileFilter *rules.FileFilter
	GitRunner  *gitcmd.Runner
	// IsGitRepo reports whether RepoDir is inside a git repository. Always
	// true when requireGit was set; may be false when scan accepts non-git
	// directories.
	IsGitRepo bool
}

// loadCommonContext validates the working directory, loads the embedded
// template, raises MaxToolRequestTimes when maxTools exceeds the default,
// resolves the absolute repo path, loads system review rules, and creates
// the global git subprocess limiter. Both review and scan callers go
// through this so the startup sequence stays consistent.
//
// requireGit=true fails fast when the directory is not a git repo (review
// path: diff concept requires git). requireGit=false allows non-git
// directories (scan path: provider falls back to filepath.Walk).
func loadCommonContext(repoDirInput, rulePath string, maxTools, maxGitProcs int, requireGit bool) (*commonContext, error) {
	tpl, err := template.LoadDefault()
	if err != nil {
		return nil, fmt.Errorf("load default template: %w", err)
	}
	if maxTools > tpl.MaxToolRequestTimes {
		tpl.MaxToolRequestTimes = maxTools
	}
	if err := tpl.Validate(); err != nil {
		return nil, fmt.Errorf("invalid config: %w", err)
	}

	repoDir, isGit, err := resolveWorkingDir(repoDirInput, requireGit)
	if err != nil {
		return nil, err
	}

	resolver, fileFilter, err := rules.NewResolver(repoDir, rulePath)
	if err != nil {
		return nil, fmt.Errorf("load rules: %w", err)
	}

	return &commonContext{
		Template:   tpl,
		RepoDir:    repoDir,
		Resolver:   resolver,
		FileFilter: fileFilter,
		GitRunner:  gitcmd.New(maxGitProcs),
		IsGitRepo:  isGit,
	}, nil
}

// resolveWorkingDir returns (absPath, isGitRepo, err). When requireGit is
// true, returns an error if the directory is not a git repo. When false,
// returns IsGitRepo=false instead of erroring (scan path uses this).
func resolveWorkingDir(input string, requireGit bool) (string, bool, error) {
	if input == "" {
		wd, err := os.Getwd()
		if err != nil {
			return "", false, fmt.Errorf("get working directory: %w", err)
		}
		input = wd
	}
	absPath, err := filepath.Abs(input)
	if err != nil {
		return "", false, fmt.Errorf("resolve absolute path: %w", err)
	}
	if _, statErr := os.Stat(absPath); statErr != nil {
		return "", false, fmt.Errorf("stat %s: %w", absPath, statErr)
	}
	out, err := runGitCmd(absPath, "rev-parse", "--git-dir")
	isGit := err == nil && len(out) > 0
	if !isGit && requireGit {
		return "", false, fmt.Errorf("%s is not a git repository", absPath)
	}
	// #287: git reports diff and `git show HEAD:<path>` paths relative to the
	// repository root, not the current directory. When `ocr review` runs from a
	// subdirectory of a monorepo, anchor RepoDir at the git top-level so those
	// root-relative paths resolve for both disk reads and git-show reads.
	// requireGit is true only for the review path; scan (requireGit=false) keeps
	// the CWD so its `git ls-files` walk stays scoped to the subdirectory.
	if isGit && requireGit {
		// runGitCmdStdout captures stdout only so git stderr notices can't
		// pollute the resolved path. --show-toplevel fails (or is empty) when
		// there is no work tree — e.g. a bare repo, where --git-dir succeeds so
		// isGit is true. Fail loudly there instead of silently reusing the
		// subdir, which would reproduce the #287 root-relative-path bug.
		top, topErr := runGitCmdStdout(absPath, "rev-parse", "--show-toplevel")
		t := strings.TrimSpace(string(top))
		if topErr != nil || t == "" {
			return "", false, fmt.Errorf("%s is a git repository without a work tree (bare repo?); cannot resolve its top level for review", absPath)
		}
		absPath = t
	}
	return absPath, isGit, nil
}

// llmRuntime bundles the LLM-side state both subcommands need once they've
// decided to actually run a session: tool definitions, an app-language
// adjusted template (mutated in place via ApplyLanguage), the LLM client,
// the resolved model name, and a fresh comment collector.
type llmRuntime struct {
	Client       llm.LLMClient
	Model        string
	PlanToolDefs []llm.ToolDef
	MainToolDefs []llm.ToolDef
	Collector    *tool.CommentCollector
	AppCfg       *Config
}

// loadLLMRuntime loads tool defs from toolConfigPath, reads the app config
// from the user's default config path (applying the configured language to
// tpl — defaulting when the config file is absent), resolves the LLM
// endpoint (honoring modelOverride from --model when non-empty), and
// returns the runtime bundle. tpl is mutated in place.
func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride string) (*llmRuntime, error) {
	toolEntries, err := toolsconfig.Load(toolConfigPath)
	if err != nil {
		return nil, fmt.Errorf("load tools: %w", err)
	}
	planToolDefs := agent.BuildToolDefs(toolEntries, true)
	mainToolDefs := agent.BuildToolDefs(toolEntries, false)

	cfgPath, err := defaultConfigPath()
	if err != nil {
		return nil, err
	}
	appCfg, err := LoadAppConfig(cfgPath)
	if err != nil {
		return nil, fmt.Errorf("load app config: %w", err)
	}
	// Apply the language directive even when the config file is missing
	// (upstream #fix: ApplyLanguage with empty lang falls back to default).
	var lang string
	if appCfg != nil {
		lang = appCfg.Language
	}
	tpl.ApplyLanguage(lang)

	ep, err := llm.ResolveEndpointWithModelOverride(cfgPath, modelOverride)
	if err != nil {
		return nil, fmt.Errorf("resolve LLM endpoint: %w", err)
	}

	return &llmRuntime{
		Client:       llm.NewLLMClient(ep),
		Model:        ep.Model,
		PlanToolDefs: planToolDefs,
		MainToolDefs: mainToolDefs,
		Collector:    tool.NewCommentCollector(),
		AppCfg:       appCfg,
	}, nil
}

// applyCLIExcludes appends user-supplied --exclude patterns (already split
// into a []string) onto cc.FileFilter.Exclude. Creates the FileFilter if
// none was returned by rule.json layers. Idempotent on empty input.
func applyCLIExcludes(cc *commonContext, patterns []string) {
	if len(patterns) == 0 {
		return
	}
	if cc.FileFilter == nil {
		cc.FileFilter = &rules.FileFilter{}
	}
	cc.FileFilter.Exclude = append(cc.FileFilter.Exclude, patterns...)
}

// excludeToolDef returns a copy of defs with any entries whose function name
// matches name removed. Used by `ocr scan` to hide tools that don't make
// sense in full-scan mode (e.g. file_read_diff).
func excludeToolDef(defs []llm.ToolDef, name string) []llm.ToolDef {
	out := make([]llm.ToolDef, 0, len(defs))
	for _, d := range defs {
		if d.Function.Name == name {
			continue
		}
		out = append(out, d)
	}
	return out
}

// quietHandle wraps a stdout.Quiet() restorer so callers can `defer
// q.Restore()` for safety while emitRunResult restores it early when the
// agent-text audience needs the trace summary on the user's terminal.
// Restore is idempotent.
type quietHandle struct {
	fn func()
}

// newQuietHandle silences stdout when outputFormat=="json" or
// audience=="agent"; otherwise the returned handle is a no-op restorer.
func newQuietHandle(outputFormat, audience string) *quietHandle {
	h := &quietHandle{}
	if outputFormat == "json" || audience == "agent" {
		h.fn = stdout.Quiet()
	}
	return h
}

// Restore re-enables stdout. Safe to call multiple times.
func (h *quietHandle) Restore() {
	if h == nil || h.fn == nil {
		return
	}
	h.fn()
	h.fn = nil
}

// ResultProvider abstracts the metadata both internal/agent.Agent and
// internal/scan.Agent expose post-run, so emitRunResult can finalize
// either without knowing which kind it has.
type ResultProvider interface {
	Diffs() []model.Diff
	FilesReviewed() int64
	TotalInputTokens() int64
	TotalOutputTokens() int64
	TotalTokensUsed() int64
	TotalCacheReadTokens() int64
	TotalCacheWriteTokens() int64
	Warnings() []agent.AgentWarning
	// ProjectSummary is the markdown project-level summary produced by
	// scan's PROJECT_SUMMARY_TASK. Empty for review mode and for scans
	// that skipped / failed the summary phase.
	ProjectSummary() string
	ToolCalls() map[string]int64
	// SessionID returns the persisted session identifier so callers can show it
	// in JSON output or failure diagnostics. Returns "" when no session was
	// created.
	SessionID() string
}

type resumeInfoProvider interface {
	ResumeInfo() *agent.ResumeInfo
}

// emitRunResult is the post-LLM-run finalization shared by `ocr review` and
// `ocr scan`: resolves comment line numbers, records telemetry, restores
// stdout early for agent-text audiences so the summary is visible, prints
// the trace summary, and writes the result in the requested format.
//
// q is the silencing handle returned by newQuietHandle; pass nil if no
// silencing was set up (in which case the early restore is a no-op).
func emitRunResult(
	ctx context.Context,
	ag ResultProvider,
	comments []model.LlmComment,
	startTime time.Time,
	outputFormat, audience string,
	q *quietHandle,
) error {
	comments = diff.ResolveLineNumbers(comments, ag.Diffs())

	duration := time.Since(startTime)
	telemetry.RecordReviewDuration(ctx, duration)
	if len(comments) > 0 {
		telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
	}

	traceID := telemetry.TraceIDFromContext(ctx)

	if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 {
		return outputJSONNoFiles(traceID)
	}

	// Agent-text audiences need stdout back before PrintTraceSummary so the
	// summary line lands on their terminal.
	if audience == "agent" && outputFormat != "json" {
		q.Restore()
	}

	if outputFormat != "json" {
		telemetry.PrintTraceSummary(ag.FilesReviewed(), int64(len(comments)),
			ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
			ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration)
	}

	if outputFormat == "json" {
		var resumeInfo *agent.ResumeInfo
		if p, ok := ag.(resumeInfoProvider); ok {
			resumeInfo = p.ResumeInfo()
		}
		return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(),
			ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
			ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration,
			ag.ProjectSummary(), ag.ToolCalls(), traceID, resumeInfo, ag.SessionID())
	}
	outputTextWithWarnings(comments, ag.Warnings())
	if summary := ag.ProjectSummary(); summary != "" {
		fmt.Printf("\n\n──────── Project Summary ────────\n\n%s\n", summary)
	}
	return nil
}
