package handlers

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/exec"
	"regexp"
	"strings"
	"time"

	"nudgebee/code-analysis-agent/agents"
	"nudgebee/code-analysis-agent/common"
	"nudgebee/code-analysis-agent/config"
	"nudgebee/code-analysis-agent/internal/credentials"
	"nudgebee/code-analysis-agent/internal/git"
	"nudgebee/code-analysis-agent/internal/gitprovider"
	"nudgebee/code-analysis-agent/internal/session"
	"nudgebee/code-analysis-agent/llm"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"github.com/tmc/langchaingo/llms"
)

// apiGitHubURLPattern matches api.github.com URLs and extracts the owner/repo.
var apiGitHubURLPattern = regexp.MustCompile(`https?://api\.github\.com/repos/([^/]+/[^/]+?)(?:\.git)?(?:/.*)?$`)

// bareRepoPattern matches a bare "owner/repo" shorthand with no scheme or host.
// Used to expand inputs like "nudgebee/nudgebee" to a full clone URL based on provider.
var bareRepoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$`)

// saasHostByProvider maps provider names to their public SaaS clone hosts.
// Self-hosted/enterprise hosts cannot be inferred and require the caller to
// pass a fully qualified URL.
var saasHostByProvider = map[string]string{
	"github":    "github.com",
	"gitlab":    "gitlab.com",
	"bitbucket": "bitbucket.org",
}

// NBAgent interface for common agent operations
type NBAgent interface {
	Execute(ctx context.Context, request agents.NBAgentRequest) (string, error)
	GetName() string
	SetLogger(logger *common.Logger)
}

type AgenticAnalyzeHandler struct {
	config            *config.Config
	gitClient         *git.GitClient
	llmClient         *llm.Client
	CredHandler       *credentials.CredentialHandler
	orchestratorAgent *agents.OrchestratorAgent
}

func NewAgenticAnalyzeHandler(cfg *config.Config, gitClient *git.GitClient, credHandler *credentials.CredentialHandler) (*AgenticAnalyzeHandler, error) {
	// Initialize LLM client
	llmClient, err := llm.NewClient(cfg)
	if err != nil {
		return nil, fmt.Errorf("failed to initialize LLM client: %w", err)
	}

	// Initialize orchestrator agent (which manages specialist + fixer agents)
	logger := common.NewLogger("agentic_handler", "orchestrator", "system", nil)
	orchestratorAgent := agents.NewOrchestratorAgent(cfg, llmClient, gitClient, logger)

	return &AgenticAnalyzeHandler{
		config:            cfg,
		gitClient:         gitClient,
		llmClient:         llmClient,
		CredHandler:       credHandler,
		orchestratorAgent: orchestratorAgent,
	}, nil
}

type GitRepository struct {
	URL           string `json:"url" binding:"required_without=LocalPath"`
	Branch        string `json:"branch,omitempty"`
	DefaultBranch string `json:"default_branch,omitempty"`
	LocalPath     string `json:"local_path,omitempty"` // Path to existing local repository
	Provider      string `json:"provider,omitempty"`   // Git provider: "github", "gitlab", or auto-detect if empty
}

type RepositoryInfo struct {
	URL        string `json:"url"`
	Branch     string `json:"branch"`
	ClonedPath string `json:"cloned_path"`
	CloneTime  string `json:"clone_time"`
}

type AgenticAnalyzeRequest struct {
	CloudAccountID    string                     `json:"cloud_account_id" binding:"required"`
	Tenant            string                     `json:"tenant" binding:"required"`
	WorkloadName      string                     `json:"workload_name"`
	WorkloadNamespace string                     `json:"workload_namespace"`
	WorkloadKind      string                     `json:"workload_kind"`
	Logs              string                     `json:"logs" binding:"required"`
	Prompt            string                     `json:"prompt"`
	AgentID           string                     `json:"agent_id,omitempty"`
	GitRepository     GitRepository              `json:"git_repository" binding:"required"`
	GitCredentials    credentials.GitCredentials `json:"git_credentials" binding:"required_without=GitRepository.LocalPath"`
	// Mode controls whether the agent is allowed to mutate code. "explore"
	// (default) is read-only Q&A / RCA; "fix" enables the CodeFixerAgent and
	// PR creation. When unset, RaisePR is used as a back-compat fallback.
	Mode             string `json:"mode,omitempty"`
	RaisePR          bool   `json:"raise_pr,omitempty"`
	EventId          string `json:"event_id,omitempty"`
	RecommendationId string `json:"recommendation_id,omitempty"`
	// WorkflowId is the originating workflow definition id. When set, the PR
	// description links back to the workflow (/workflow/<id>).
	WorkflowId     string               `json:"workflow_id,omitempty"`
	AccountId      string               `json:"account_id,omitempty"`
	ConversationId string               `json:"conversation_id,omitempty"`
	MessageId      string               `json:"message_id,omitempty"`
	BuildConfig    *session.BuildConfig `json:"build_config,omitempty"`
	// Skills is a pre-rendered <skills> block of operator-authored skills mapped
	// to the code agent. Resolved and forwarded by llm-server (which owns the
	// skills DB); injected verbatim into the internal agent prompts.
	Skills string `json:"skills,omitempty"`

	// PR followup fields — used to address CI failures and review comments on existing PRs
	Followup bool   `json:"followup,omitempty"`
	PRURL    string `json:"pr_url,omitempty"`
	PRBranch string `json:"pr_branch,omitempty"`
}

type PRInfo struct {
	Number      int    `json:"number,omitempty"`
	IID         int    `json:"iid,omitempty"` // GitLab internal ID (merge request IID)
	Title       string `json:"title,omitempty"`
	Author      string `json:"author,omitempty"`
	URL         string `json:"url,omitempty"`
	WebURL      string `json:"web_url,omitempty"` // GitLab web URL format
	State       string `json:"state,omitempty"`
	Branch      string `json:"branch,omitempty"`
	CreatedAt   string `json:"created_at,omitempty"`
	MergedAt    string `json:"merged_at,omitempty"`
	Description string `json:"description,omitempty"`
	Provider    string `json:"provider,omitempty"` // "github" or "gitlab"

	// Duplicate detection fields
	Status        string   `json:"status,omitempty"`         // e.g., "skipped_duplicate", "success"
	DuplicateOf   string   `json:"duplicate_of,omitempty"`   // URL of existing PR if duplicate
	Message       string   `json:"message,omitempty"`        // Status message
	FilesModified []string `json:"files_modified,omitempty"` // Files that would have been modified
}

type CommitInfo struct {
	Hash    string `json:"hash"`
	Author  string `json:"author"`
	Date    string `json:"date"`
	Message string `json:"message,omitempty"`
	Changes string `json:"changes,omitempty"`
}

type AlternativeFix struct {
	Approach   string `json:"approach"`
	Code       string `json:"code"`
	Pros       string `json:"pros"`
	Cons       string `json:"cons"`
	Complexity string `json:"complexity"`
}

type SemanticInfo struct {
	FunctionDefinition string   `json:"function_definition,omitempty"`
	CallHierarchy      []string `json:"call_hierarchy,omitempty"`
	Dependencies       []string `json:"dependencies,omitempty"`
}

// handlerCitation mirrors tools.Citation but lives in the handler package so
// the wire response struct doesn't take a dependency on the tools package.
type handlerCitation struct {
	FilePath  string `json:"file_path"`
	LineStart int    `json:"line_start"`
	LineEnd   int    `json:"line_end,omitempty"`
	Snippet   string `json:"snippet"`
	Note      string `json:"note,omitempty"`
}

type AnalysisResult struct {
	// "explore" or "fix" — echoed from the orchestrator so callers know which
	// schema to render (explore omits all fix-only fields below).
	Mode string `json:"mode,omitempty"`
	// EXISTING FIELDS - Must maintain exact same data as before.
	// FixedCode/GitDiff are fix-only and use omitempty so they don't surface
	// as empty strings on explore-mode responses.
	Title              string       `json:"title"`       // Short descriptive title for the issue/fix
	Description        string       `json:"description"` // Detailed description of the issue and solution
	FilePath           string       `json:"file_path"`
	LineNumber         int          `json:"line_number"`
	ErrorMessage       string       `json:"error_message,omitempty"`
	OriginalCode       string       `json:"original_code,omitempty"`
	FixedCode          string       `json:"fixed_code,omitempty"`
	GitDiff            string       `json:"git_diff,omitempty"`
	Commits            []CommitInfo `json:"commits,omitempty"`
	AutoMatedFixPRInfo *PRInfo      `json:"automated_fix_pr_info,omitempty"`
	PRList             []PRInfo     `json:"pr_list,omitempty"`

	// EXPLORE-MODE STRUCTURED CONTRACT
	// Answer is the headline (no markdown). Citations are the structured
	// evidence — every claim in Answer must be backed by a citation.
	// Caveats / FollowUpSuggestions provide optional context for the chat UI.
	Answer              string            `json:"answer,omitempty"`
	Citations           []handlerCitation `json:"citations,omitempty"`
	Caveats             []string          `json:"caveats,omitempty"`
	FollowUpSuggestions []string          `json:"follow_up_suggestions,omitempty"`

	// NEW ENHANCEMENT FIELDS - Safe to add with omitempty.
	// InvestigationTrail must be []string to match the LLM's output schema
	// (was string, which silently routed every parse to a lossy fallback).
	ConfidenceScore    string           `json:"confidence_score,omitempty"`
	InvestigationTrail []string         `json:"investigation_trail,omitempty"`
	CodeContext        string           `json:"code_context,omitempty"`
	AffectedComponents []string         `json:"affected_components,omitempty"`
	RootCauseAnalysis  string           `json:"root_cause_analysis,omitempty"`
	AlternativeFixes   []AlternativeFix `json:"alternative_fixes,omitempty"`
	RelatedIssues      []string         `json:"related_issues,omitempty"`
	SemanticAnalysis   *SemanticInfo    `json:"semantic_analysis,omitempty"`

	// PIPELINE STATUS FIELDS - Expose review/build/fix details for transparency
	RequiresFix        bool           `json:"requires_fix,omitempty"`
	ExecutionStatus    string         `json:"execution_status,omitempty"`    // success, failed, partial_success, no_op (followup-only)
	ExecutionSummary   string         `json:"execution_summary,omitempty"`   // CodeFixer's summary of what it did
	FilesModified      any            `json:"files_modified,omitempty"`      // List of files the fixer changed
	VerificationPassed any            `json:"verification_passed,omitempty"` // Whether lint/build passed
	PRCreationStatus   string         `json:"pr_creation_status,omitempty"`  // success, skipped, failed
	PRCreationReason   string         `json:"pr_creation_reason,omitempty"`  // Why PR was skipped/failed
	Review             map[string]any `json:"review,omitempty"`              // Review agent feedback, issues, syntax errors
	BuildVerification  map[string]any `json:"build_verification,omitempty"`  // Lint/build command results
	FailureSummary     string         `json:"failure_summary,omitempty"`     // Human-readable summary when pipeline fails
}

type AgenticAnalyzeResponse struct {
	Success         bool             `json:"success"`
	AnalysisID      string           `json:"analysis_id"`
	AgentResponse   *AnalysisResult  `json:"agent_response"`
	ToolInvocations []ToolInvocation `json:"tool_invocations,omitempty"`
	ProcessingTime  string           `json:"processing_time"`
	Repository      RepositoryInfo   `json:"repository"`
	TokenUsage      *TokenUsage      `json:"token_usage,omitempty"`
	Error           string           `json:"error,omitempty"`
}

type TokenUsage struct {
	PromptTokens        int    `json:"prompt_tokens"`
	CompletionTokens    int    `json:"completion_tokens"`
	TotalTokens         int    `json:"total_tokens"`
	CachedContentTokens int    `json:"cached_content_tokens"`
	Model               string `json:"model"`
	Provider            string `json:"provider"`
}

type ToolInvocation struct {
	ToolName  string `json:"tool_name"`
	Input     any    `json:"input"`
	Output    any    `json:"output"`
	Status    string `json:"status"`
	Duration  string `json:"duration"`
	Timestamp string `json:"timestamp"`
}

func (ah *AgenticAnalyzeHandler) HandleAgenticAnalyze(ctx context.Context, req AgenticAnalyzeRequest) (*AgenticAnalyzeResponse, error) {
	startTime := time.Now()

	// Perform agentic analysis
	response, err := ah.PerformAgenticAnalysis(ctx, req)
	if err != nil {
		return &AgenticAnalyzeResponse{
			Success:    false,
			AnalysisID: req.ConversationId,
			Error:      fmt.Sprintf("Analysis failed: %s", err.Error()),
		}, nil
	}

	response.ProcessingTime = time.Since(startTime).String()
	return response, nil
}

// newAnalysisID returns a fresh, globally unique identifier for an /analyze
// invocation. The ID is the in-memory key for the progress store
// (common.InitAnalysis / common.GetAnalysisState / common.CleanupAnalysis).
//
// Why this MUST NOT be derived from req.ConversationId or any other client
// field: a single conversation can issue multiple agent_code_2 calls
// back-to-back (e.g. an explore call followed by a fix call). When call #1
// completes, it schedules a 5-minute deferred CleanupAnalysis(id). If call #2
// reuses the same id, the deferred cleanup from #1 wipes #2's still-running
// state, and /status/{id} starts returning 404 mid-poll. Always generating a
// fresh id per call removes that collision class entirely.
func newAnalysisID() string {
	return fmt.Sprintf("analysis_%d_%s", time.Now().UnixNano(), uuid.NewString())
}

func (ah *AgenticAnalyzeHandler) HandleAnalyze(c *gin.Context) {
	var req AgenticAnalyzeRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"success": false,
			"error":   fmt.Sprintf("Invalid request: %s", err.Error()),
		})
		return
	}

	// Workload fields are required for normal analysis but not for PR followup
	if !req.Followup {
		if req.WorkloadName == "" || req.WorkloadNamespace == "" || req.WorkloadKind == "" {
			c.JSON(http.StatusBadRequest, gin.H{
				"success": false,
				"error":   "workload_name, workload_namespace, and workload_kind are required for non-followup requests",
			})
			return
		}
	}

	analysisID := newAnalysisID()

	common.InitAnalysis(analysisID)

	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), ah.config.Analysis.MaxProcessingTime)
		defer cancel()

		response, err := ah.HandleAgenticAnalyze(ctx, req)
		if err != nil {
			common.FailAnalysis(analysisID, err.Error())
		} else {
			common.CompleteAnalysis(analysisID, response)
		}

		// Keep result available for 5 minutes after completion, then clean up
		time.AfterFunc(5*time.Minute, func() {
			common.CleanupAnalysis(analysisID)
		})
	}()

	c.JSON(http.StatusAccepted, gin.H{
		"success":     true,
		"analysis_id": analysisID,
		"status":      "running",
	})
}

// HandleStatus returns the current progress and result of an async analysis.
func (ah *AgenticAnalyzeHandler) HandleStatus(c *gin.Context) {
	analysisID := strings.TrimPrefix(c.Param("id"), "/")
	state := common.GetAnalysisState(analysisID)
	if state == nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "analysis not found"})
		return
	}

	resp := gin.H{
		"analysis_id": analysisID,
		"status":      state.Status,
		"progress":    state.Progress,
	}
	if state.Status == "completed" {
		resp["result"] = state.Result
	}
	if state.Status == "failed" {
		resp["error"] = state.Error
	}
	c.JSON(http.StatusOK, resp)
}

func (ah *AgenticAnalyzeHandler) PerformAgenticAnalysis(ctx context.Context, req AgenticAnalyzeRequest) (*AgenticAnalyzeResponse, error) {
	// Snapshot token usage before this analysis to compute per-request delta later.
	// The llmClient is a singleton shared across requests, so we track the delta
	// rather than the cumulative total.
	tokensBefore := ah.llmClient.SnapshotTokenUsage()

	// Normalize and validate request before processing
	if err := ah.normalizeAndValidateRequest(&req); err != nil {
		return nil, fmt.Errorf("request validation failed: %w", err)
	}

	// Debug: Log what we received in the handler
	log.Printf("DEBUG HANDLER: Received request - GitRepository.URL='%s', GitRepository.LocalPath='%s', GitRepository.Branch='%s', Tenant='%s', AgentID='%s'",
		req.GitRepository.URL, req.GitRepository.LocalPath, req.GitRepository.Branch, req.Tenant, req.AgentID)

	// Create logger for this analysis session
	logger := common.NewLogger(req.ConversationId, req.GitRepository.URL, req.Tenant, map[string]any{
		"repository": req.GitRepository.URL,
		"branch":     req.GitRepository.Branch,
	})

	// Resolve credentials only if not using local repository and CredHandler is available
	var resolvedCreds *credentials.ResolvedCredentials
	if ah.CredHandler != nil && req.GitRepository.LocalPath == "" {
		var err error
		resolvedCreds, err = ah.CredHandler.ResolveCredentials(req.GitCredentials)
		if err != nil {
			return nil, fmt.Errorf("failed to resolve credentials: %s", err.Error())
		}
	}

	// PR followup mode — route to PRFollowupAgent instead of orchestrator
	if req.Followup && req.PRURL != "" {
		return ah.performFollowupAnalysis(ctx, req, logger, resolvedCreds)
	}

	// Configure all agents with the logger
	ah.orchestratorAgent.SetLogger(logger)

	// Handle repository cloning for remote URLs
	var repositoryPath string
	log.Printf("DEBUG HANDLER: Repository decision logic - LocalPath='%s', URL='%s'", req.GitRepository.LocalPath, req.GitRepository.URL)

	if req.GitRepository.LocalPath != "" {
		// Use existing local path
		repositoryPath = req.GitRepository.LocalPath

		// If repository URL is empty, try to detect it from local git remote
		if req.GitRepository.URL == "" {
			if detectedURL := ah.detectRepositoryURL(repositoryPath); detectedURL != "" {
				req.GitRepository.URL = detectedURL
				logger.Log(common.EventAnalysisStart, "Detected repository URL from local git", map[string]any{
					"detected_url": detectedURL,
				})
			}
		}

		logger.Log(common.EventAnalysisStart, "Using existing local repository", map[string]any{
			"path": repositoryPath,
			"url":  req.GitRepository.URL,
		})
	} else if req.GitRepository.URL != "" {
		// Repository URL provided - create temp directory for agent-managed cloning
		sanitizedID := strings.Map(func(r rune) rune {
			if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
				return r
			}
			return '_'
		}, req.ConversationId)
		tempDir, err := os.MkdirTemp("", fmt.Sprintf("code-analysis-%s-", sanitizedID))
		if err != nil {
			return nil, fmt.Errorf("failed to create temporary directory: %w", err)
		}
		repositoryPath = tempDir

		logger.Log(common.EventAnalysisStart, "Repository URL provided - agents will handle cloning as needed", map[string]any{
			"url":            req.GitRepository.URL,
			"temp_workspace": repositoryPath,
		})
	} else {
		// No repository provided - this should be handled appropriately
		if req.Logs != "" {
			// For log-only analysis, warn but continue without repository
			logger.Log(common.EventAnalysisStart, "No repository provided - performing log-only analysis", map[string]any{
				"logs_length": len(req.Logs),
			})
			repositoryPath = "" // Explicitly set to empty to indicate no repository
		} else {
			// No logs and no repository - this is invalid
			return nil, fmt.Errorf("either repository URL or application logs must be provided for analysis")
		}
	}
	// Log analysis start
	logger.AnalysisStart(req.GitRepository.URL, len(req.Logs))

	// Create tool invocation tracker for this analysis session
	toolTracker := common.NewToolInvocationTracker(req.ConversationId)

	// Enable tool tracking for all agents
	ah.enableToolTracking(toolTracker, logger)

	// Create NBAgentRequest with proper query construction
	var query string
	if req.Logs == "" {
		// No logs provided - use prompt as main query
		query = req.Prompt
	} else {
		// Logs provided - combine prompt with log analysis instruction
		if req.Prompt != "" && req.Prompt != "Analyze the logs for errors" {
			query = fmt.Sprintf("%s\n\nAnalyze the following logs:\n%s", req.Prompt, req.Logs)
		} else {
			query = fmt.Sprintf("Analyze the following error logs and identify the root cause:\n\n%s", req.Logs)
		}
	}

	messageId := fmt.Sprintf("msg_%d", time.Now().Unix())
	if req.MessageId != "" {
		messageId = req.MessageId
	}

	agentRequest := agents.NBAgentRequest{
		Query:                 query,
		AccountId:             req.CloudAccountID,
		ConversationId:        req.ConversationId,
		AgentId:               req.AgentID,
		MessageId:             messageId,
		UserId:                req.Tenant,
		ConversationContext:   ah.createConversationContext(req),
		QueryContext:          ah.createQueryContext(req),
		QueryConfig:           ah.createQueryConfigWithPath(req, resolvedCreds, repositoryPath),
		EnableQueryRefinement: true,
		Mode:                  req.Mode,
		RaisePR:               req.RaisePR,
		EventId:               req.EventId,
		RecommendationId:      req.RecommendationId,
		Skills:                req.Skills,
	}

	// Determine which agent to use based on request content
	selectedAgent := ah.selectAgent(agentRequest, repositoryPath)

	// Configure the selected agent with the logger
	selectedAgent.SetLogger(logger)

	// Enable tool tracking for the selected agent
	if trackableAgent, ok := selectedAgent.(ToolTrackable); ok {
		trackableAgent.EnableToolTracking(toolTracker, logger)
		logger.Log(common.EventAnalysisStart, "Tool tracking enabled for agent", map[string]any{
			"agent_name": selectedAgent.GetName(),
		})
	} else {
		logger.Log(common.EventAnalysisStart, "Tool tracking not available for agent", map[string]any{
			"agent_name": selectedAgent.GetName(),
		})
	}

	// Execute agent analysis
	logger.Log(common.EventAnalysisStart, "Starting agent execution", map[string]any{
		"agent_id":       agentRequest.AgentId,
		"selected_agent": selectedAgent.GetName(),
		"repository":     req.GitRepository.URL,
	})

	agentResponseStr, err := selectedAgent.Execute(ctx, agentRequest)
	if err != nil {
		logger.AnalysisFailure(err, "agent_execution")
		return nil, fmt.Errorf("agent execution failed: %w", err)
	}

	logger.Log(common.EventAnalysisComplete, "Agent execution completed", map[string]any{
		"response_length": len(agentResponseStr),
	})

	// Parse the agent response JSON
	logger.Log(common.EventResultParsed, "Parsing agent response", map[string]any{
		"response_preview": func() string {
			if len(agentResponseStr) > 200 {
				return agentResponseStr[:200] + "..."
			}
			return agentResponseStr
		}(),
	})

	// Try to parse with custom handling for PR info
	agentResponse, err := ah.parseAgentResponse(agentResponseStr)
	parseFailed := err != nil
	if parseFailed {
		responsePreview := agentResponseStr
		if len(agentResponseStr) > 500 {
			responsePreview = agentResponseStr[:500] + "..."
		}
		logger.Error(common.EventResultParsed, "Failed to parse agent response", err, map[string]any{
			"response_preview": responsePreview,
		})
		// If parsing fails, create a fallback response
		agentResponse = &AnalysisResult{
			Title:              "Analysis Response Parse Error",
			Description:        "The code analysis agent completed execution but the response could not be parsed properly. This may indicate a formatting issue in the agent's output. Manual review of the logs and repository may be required to determine the actual issue.",
			FilePath:           "unknown",
			LineNumber:         0,
			ErrorMessage:       "Failed to parse agent response",
			OriginalCode:       "Parse error occurred",
			FixedCode:          "Manual investigation required",
			GitDiff:            "--- a/unknown\n+++ b/unknown\n@@ -0,0 +0,0 @@\n Parse error",
			Commits:            []CommitInfo{{Hash: "unknown", Author: "unknown", Date: "unknown"}},
			AutoMatedFixPRInfo: nil,
		}
	}

	// Validate relevance using LLM: Check if the agent response addresses the user's actual request.
	// Skip on parse-error fallback: that synthetic AnalysisResult is a formatting failure, not an
	// off-topic analysis. Running the relevance check against it always returns "not relevant"
	// (because "Manual investigation required" never matches a user's specific issue), which then
	// trips the upstream irrelevance marker — causing llm-server to cache "not relevant" in the
	// per-message retry guard and permanently lock out retries that could have recovered.
	var relevanceCheck *RelevanceCheckResult
	if !parseFailed {
		relevanceCheck, err = ah.validateResponseRelevanceWithLLM(agentResponse, req, logger)
	}
	if err != nil {
		logger.Error(common.EventAnalysisFailure, "Failed to validate response relevance", err, nil)
	} else if relevanceCheck != nil && !relevanceCheck.IsRelevant {
		logger.Error(common.EventAnalysisFailure, "Agent analysis is not relevant to user request", fmt.Errorf("off-topic analysis detected"), map[string]any{
			"user_logs":         req.Logs,
			"user_prompt":       req.Prompt,
			"agent_title":       agentResponse.Title,
			"agent_description": agentResponse.Description,
			"llm_reasoning":     relevanceCheck.Reasoning,
		})

		// Create a more focused response using LLM insights
		agentResponse = &AnalysisResult{
			Title:              "Analysis Focus Issue - Manual Review Required",
			Description:        fmt.Sprintf("The automated analysis may not be directly addressing your specific issue. %s\n\nOriginal Analysis Found: %s\n\nRecommendation: %s", relevanceCheck.Reasoning, agentResponse.Title, relevanceCheck.Recommendation),
			FilePath:           agentResponse.FilePath,
			LineNumber:         agentResponse.LineNumber,
			ErrorMessage:       "Analysis may not match the specific issue described",
			OriginalCode:       agentResponse.OriginalCode,
			FixedCode:          agentResponse.FixedCode,
			GitDiff:            agentResponse.GitDiff,
			Commits:            agentResponse.Commits,
			AutoMatedFixPRInfo: agentResponse.AutoMatedFixPRInfo,
			PRList:             agentResponse.PRList,
		}
	}

	// Get real tool invocations from tracker
	var toolInvocations []ToolInvocation
	trackedInvocations := toolTracker.GetInvocations()

	logger.Log(common.EventAnalysisComplete, "Tool invocation collection", map[string]any{
		"tracked_invocations_count": len(trackedInvocations),
		"agent_has_commits":         len(agentResponse.Commits) > 0,
		"agent_has_filepath":        agentResponse.FilePath != "",
	})

	if len(trackedInvocations) > 0 {
		toolInvocations = ah.convertTrackedInvocations(trackedInvocations)
		logger.Log(common.EventAnalysisComplete, "Using tracked tool invocations", map[string]any{
			"invocation_count": len(toolInvocations),
		})
	} else if len(agentResponse.Commits) > 0 || agentResponse.FilePath != "" {
		// Fallback to generated ones if no real invocations captured
		toolInvocations = ah.extractRealToolInvocations(agentResponse, req.GitRepository.URL)
		logger.Log(common.EventAnalysisComplete, "Using fallback generated tool invocations", map[string]any{
			"invocation_count": len(toolInvocations),
		})
	} else {
		logger.Log(common.EventAnalysisComplete, "No tool invocations to include", nil)
	}

	// Log successful completion
	logger.AnalysisComplete(agentResponse, len(toolInvocations))
	logger.FinalAnswer(agentResponse, "agent")

	// Collect per-request token usage by computing delta from the pre-analysis snapshot.
	// The llmClient is a singleton, so the cumulative total includes all prior requests.
	// Delta gives us the tokens consumed by THIS analysis only.
	var tokenUsage *TokenUsage
	if ah.llmClient != nil {
		tokensAfter := ah.llmClient.SnapshotTokenUsage()
		delta := llm.TokenUsageDelta(tokensBefore, tokensAfter)
		if delta.TotalTokens > 0 {
			tokenUsage = &TokenUsage{
				PromptTokens:        delta.PromptTokens,
				CompletionTokens:    delta.CompletionTokens,
				TotalTokens:         delta.TotalTokens,
				CachedContentTokens: delta.CachedContentTokens,
				Model:               delta.Model,
				Provider:            delta.Provider,
			}
			logger.Log(common.EventAnalysisComplete, "Token usage collected for final response", map[string]any{
				"total_tokens":          delta.TotalTokens,
				"prompt_tokens":         delta.PromptTokens,
				"completion_tokens":     delta.CompletionTokens,
				"cached_content_tokens": delta.CachedContentTokens,
				"model":                 delta.Model,
				"provider":              delta.Provider,
			})
		}
	}

	response := &AgenticAnalyzeResponse{
		Success:         true,
		AnalysisID:      req.ConversationId,
		AgentResponse:   agentResponse,
		ToolInvocations: toolInvocations,
		Repository:      ah.createRepositoryInfoWithPath(req, repositoryPath),
		TokenUsage:      tokenUsage,
	}

	// Sanitize any credentials that might have leaked into the response
	ah.sanitizeCredentials(response)

	// Also sanitize the raw agent response string to prevent any credential leakage
	ah.sanitizeAgentResponseString(&agentResponseStr)

	return response, nil
}

// parseAgentResponse handles parsing with custom logic for PR info structure
func (ah *AgenticAnalyzeHandler) parseAgentResponse(responseStr string) (*AnalysisResult, error) {
	// First try standard parsing
	var result AnalysisResult
	if err := json.Unmarshal([]byte(responseStr), &result); err == nil {
		return &result, nil
	}

	// If that fails, try parsing into a more flexible structure
	var flexibleResult map[string]any
	if err := json.Unmarshal([]byte(responseStr), &flexibleResult); err != nil {
		return nil, fmt.Errorf("failed to parse JSON: %w", err)
	}

	// Manual field extraction with type conversion
	result = AnalysisResult{
		Title:        ah.getStringField(flexibleResult, "title"),
		Description:  ah.getStringField(flexibleResult, "description"),
		FilePath:     ah.getStringField(flexibleResult, "file_path"),
		LineNumber:   ah.getIntField(flexibleResult, "line_number"),
		ErrorMessage: ah.getStringField(flexibleResult, "error_message"),
		OriginalCode: ah.getStringField(flexibleResult, "original_code"),
		FixedCode:    ah.getStringField(flexibleResult, "fixed_code"),
		GitDiff:      ah.getStringField(flexibleResult, "git_diff"),
	}

	// Handle PR list with flexible parsing (array of PRs)
	if prListRaw, exists := flexibleResult["pr_list"]; exists && prListRaw != nil {
		if prListArray, ok := prListRaw.([]any); ok {
			result.PRList = make([]PRInfo, 0, len(prListArray))
			for _, prRaw := range prListArray {
				if prMap, ok := prRaw.(map[string]any); ok {
					prInfo := PRInfo{
						Number:      ah.getIntField(prMap, "number"),
						Title:       ah.getStringField(prMap, "title"),
						Author:      ah.extractAuthorString(prMap),
						URL:         ah.getStringField(prMap, "url"),
						State:       ah.getStringField(prMap, "state"),
						CreatedAt:   ah.getStringField(prMap, "createdAt"),
						MergedAt:    ah.getStringField(prMap, "mergedAt"),
						Description: ah.getStringField(prMap, "description"),
					}
					result.PRList = append(result.PRList, prInfo)
				}
			}
		}
	}

	// Handle automated_fix_pr_info from orchestrator (new field name) or fix_pr (legacy)
	var fixPRMap map[string]any
	if automatedFixPRRaw, exists := flexibleResult["automated_fix_pr_info"]; exists && automatedFixPRRaw != nil {
		fixPRMap, _ = automatedFixPRRaw.(map[string]any)
	} else if fixPRRaw, exists := flexibleResult["fix_pr"]; exists && fixPRRaw != nil {
		// Fallback to legacy fix_pr field for backward compatibility
		fixPRMap, _ = fixPRRaw.(map[string]any)
	}

	if fixPRMap != nil {
		// Get URL - check both "url" and "pr_url" (used in duplicate responses)
		prURL := ah.getStringField(fixPRMap, "url")
		if prURL == "" {
			prURL = ah.getStringField(fixPRMap, "pr_url")
		}

		// Create PRInfo from the PR data
		fixPRInfo := PRInfo{
			Title:  ah.getStringField(fixPRMap, "title"),
			URL:    prURL,
			State:  "OPEN", // Default state for newly created PR
			Branch: ah.getStringField(fixPRMap, "branch"),
		}

		// Handle different PR statuses
		status := ah.getStringField(fixPRMap, "status")
		fixPRInfo.Status = status

		switch status {
		case "skipped_duplicate":
			fixPRInfo.State = "SKIPPED_DUPLICATE"
			fixPRInfo.DuplicateOf = ah.getStringField(fixPRMap, "duplicate_of")
			fixPRInfo.Message = ah.getStringField(fixPRMap, "message")
			// Extract files_modified array
			if filesRaw, ok := fixPRMap["files_modified"]; ok {
				if filesArray, ok := filesRaw.([]any); ok {
					for _, f := range filesArray {
						if fStr, ok := f.(string); ok {
							fixPRInfo.FilesModified = append(fixPRInfo.FilesModified, fStr)
						}
					}
				}
			}
		case "changes_applied_pr_failed":
			fixPRInfo.State = "PR_FAILED"
			fixPRInfo.Message = ah.getStringField(fixPRMap, "message")
		case "success":
			fixPRInfo.State = "OPEN"
		}

		result.AutoMatedFixPRInfo = &fixPRInfo

		// Extract additional fields from fix_pr to populate AnalysisResult
		if gitDiff := ah.getStringField(fixPRMap, "git_diff"); gitDiff != "" {
			result.GitDiff = gitDiff
		}
		if originalCode := ah.getStringField(fixPRMap, "original_code"); originalCode != "" {
			result.OriginalCode = originalCode
		}
		if fixedCode := ah.getStringField(fixPRMap, "fixed_code"); fixedCode != "" {
			result.FixedCode = fixedCode
		}
		if filePath := ah.getStringField(fixPRMap, "file_path"); filePath != "" {
			result.FilePath = filePath
		}
	}

	// Handle commits array with flexible parsing
	if commitsRaw, exists := flexibleResult["commits"]; exists && commitsRaw != nil {
		if commitsArray, ok := commitsRaw.([]any); ok {
			result.Commits = make([]CommitInfo, 0, len(commitsArray))
			for _, commitRaw := range commitsArray {
				if commitMap, ok := commitRaw.(map[string]any); ok {
					commitInfo := CommitInfo{
						Hash:    ah.getStringField(commitMap, "hash"),
						Author:  ah.getStringField(commitMap, "author"),
						Date:    ah.getStringField(commitMap, "date"),
						Message: ah.getStringField(commitMap, "message"),
						Changes: ah.getStringField(commitMap, "changes"),
					}
					result.Commits = append(result.Commits, commitInfo)
				}
			}
		}
	} else {
		// Fallback: try to construct from old field names for backward compatibility
		commitHash := ah.getStringField(flexibleResult, "commit_hash")
		author := ah.getStringField(flexibleResult, "author")
		commitDate := ah.getStringField(flexibleResult, "commit_date")
		if commitHash != "" || author != "" || commitDate != "" {
			result.Commits = []CommitInfo{{
				Hash:   commitHash,
				Author: author,
				Date:   commitDate,
			}}
		}
	}

	// Parse new enhancement fields
	result.Mode = ah.getStringField(flexibleResult, "mode")
	result.ConfidenceScore = ah.getStringField(flexibleResult, "confidence_score")
	result.InvestigationTrail = ah.getStringSliceField(flexibleResult, "investigation_trail")
	result.CodeContext = ah.getStringField(flexibleResult, "code_context")
	result.RootCauseAnalysis = ah.getStringField(flexibleResult, "root_cause_analysis")

	// Parse explore-mode contract fields
	result.Answer = ah.getStringField(flexibleResult, "answer")
	result.Citations = ah.getCitationsField(flexibleResult, "citations")
	result.Caveats = ah.getStringSliceField(flexibleResult, "caveats")
	result.FollowUpSuggestions = ah.getStringSliceField(flexibleResult, "follow_up_suggestions")

	// Parse affected components array
	if affectedRaw, exists := flexibleResult["affected_components"]; exists && affectedRaw != nil {
		if affectedArray, ok := affectedRaw.([]any); ok {
			result.AffectedComponents = make([]string, 0, len(affectedArray))
			for _, item := range affectedArray {
				if str, ok := item.(string); ok {
					result.AffectedComponents = append(result.AffectedComponents, str)
				}
			}
		}
	}

	// Parse related issues array
	if relatedRaw, exists := flexibleResult["related_issues"]; exists && relatedRaw != nil {
		if relatedArray, ok := relatedRaw.([]any); ok {
			result.RelatedIssues = make([]string, 0, len(relatedArray))
			for _, item := range relatedArray {
				if str, ok := item.(string); ok {
					result.RelatedIssues = append(result.RelatedIssues, str)
				}
			}
		}
	}

	// Parse alternative fixes array
	if fixesRaw, exists := flexibleResult["alternative_fixes"]; exists && fixesRaw != nil {
		if fixesArray, ok := fixesRaw.([]any); ok {
			result.AlternativeFixes = make([]AlternativeFix, 0, len(fixesArray))
			for _, fixRaw := range fixesArray {
				if fixMap, ok := fixRaw.(map[string]any); ok {
					fix := AlternativeFix{
						Approach:   ah.getStringField(fixMap, "approach"),
						Code:       ah.getStringField(fixMap, "code"),
						Pros:       ah.getStringField(fixMap, "pros"),
						Cons:       ah.getStringField(fixMap, "cons"),
						Complexity: ah.getStringField(fixMap, "complexity"),
					}
					result.AlternativeFixes = append(result.AlternativeFixes, fix)
				}
			}
		}
	}

	// Parse semantic analysis
	if semanticRaw, exists := flexibleResult["semantic_analysis"]; exists && semanticRaw != nil {
		if semanticMap, ok := semanticRaw.(map[string]any); ok {
			semantic := &SemanticInfo{
				FunctionDefinition: ah.getStringField(semanticMap, "function_definition"),
			}

			// Parse call hierarchy array
			if callHierarchyRaw, exists := semanticMap["call_hierarchy"]; exists && callHierarchyRaw != nil {
				if callArray, ok := callHierarchyRaw.([]any); ok {
					semantic.CallHierarchy = make([]string, 0, len(callArray))
					for _, item := range callArray {
						if str, ok := item.(string); ok {
							semantic.CallHierarchy = append(semantic.CallHierarchy, str)
						}
					}
				}
			}

			// Parse dependencies array
			if depsRaw, exists := semanticMap["dependencies"]; exists && depsRaw != nil {
				if depsArray, ok := depsRaw.([]any); ok {
					semantic.Dependencies = make([]string, 0, len(depsArray))
					for _, item := range depsArray {
						if str, ok := item.(string); ok {
							semantic.Dependencies = append(semantic.Dependencies, str)
						}
					}
				}
			}

			result.SemanticAnalysis = semantic
		}
	}

	// Parse pipeline status fields
	result.ExecutionStatus = ah.getStringField(flexibleResult, "execution_status")
	result.ExecutionSummary = ah.getStringField(flexibleResult, "execution_summary")
	result.PRCreationStatus = ah.getStringField(flexibleResult, "pr_creation_status")
	result.PRCreationReason = ah.getStringField(flexibleResult, "pr_creation_reason")
	result.FailureSummary = ah.getStringField(flexibleResult, "failure_summary")
	if fm, exists := flexibleResult["files_modified"]; exists {
		result.FilesModified = fm
	}
	if vp, exists := flexibleResult["verification_passed"]; exists {
		result.VerificationPassed = vp
	}
	if rf, ok := flexibleResult["requires_fix"].(bool); ok {
		result.RequiresFix = rf
	}
	if reviewData, ok := flexibleResult["review"].(map[string]any); ok {
		result.Review = reviewData
	}
	if buildData, ok := flexibleResult["build_verification"].(map[string]any); ok {
		result.BuildVerification = buildData
	}

	return &result, nil
}

func (ah *AgenticAnalyzeHandler) getStringField(data map[string]any, key string) string {
	if value, exists := data[key]; exists {
		if str, ok := value.(string); ok {
			return str
		}
		// Try to convert other types to string
		return fmt.Sprintf("%v", value)
	}
	return ""
}

func (ah *AgenticAnalyzeHandler) getIntField(data map[string]any, key string) int {
	if value, exists := data[key]; exists {
		if num, ok := value.(float64); ok {
			return int(num)
		}
		if num, ok := value.(int); ok {
			return num
		}
	}
	return 0
}

// getStringSliceField extracts a []string from a flexible JSON map.
// Accepts a JSON array of strings; non-string entries are coerced via %v.
// Returns nil if the field is absent or not a slice.
func (ah *AgenticAnalyzeHandler) getStringSliceField(data map[string]any, key string) []string {
	raw, exists := data[key]
	if !exists || raw == nil {
		return nil
	}
	arr, ok := raw.([]any)
	if !ok {
		return nil
	}
	out := make([]string, 0, len(arr))
	for _, item := range arr {
		if s, ok := item.(string); ok {
			out = append(out, s)
		} else {
			out = append(out, fmt.Sprintf("%v", item))
		}
	}
	return out
}

// getCitationsField extracts a []handlerCitation from a flexible JSON map.
// Tolerant of missing fields per entry — invalid citations are skipped at
// validation time, not here.
func (ah *AgenticAnalyzeHandler) getCitationsField(data map[string]any, key string) []handlerCitation {
	raw, exists := data[key]
	if !exists || raw == nil {
		return nil
	}
	arr, ok := raw.([]any)
	if !ok {
		return nil
	}
	out := make([]handlerCitation, 0, len(arr))
	for _, item := range arr {
		m, ok := item.(map[string]any)
		if !ok {
			continue
		}
		out = append(out, handlerCitation{
			FilePath:  ah.getStringField(m, "file_path"),
			LineStart: ah.getIntField(m, "line_start"),
			LineEnd:   ah.getIntField(m, "line_end"),
			Snippet:   ah.getStringField(m, "snippet"),
			Note:      ah.getStringField(m, "note"),
		})
	}
	return out
}

// extractAuthorString handles both string and object formats for author field
func (ah *AgenticAnalyzeHandler) extractAuthorString(data map[string]any) string {
	if authorRaw, exists := data["author"]; exists {
		// If it's already a string, return it
		if authorStr, ok := authorRaw.(string); ok {
			return authorStr
		}

		// If it's an object (GitHub CLI format), extract the login or name
		if authorObj, ok := authorRaw.(map[string]any); ok {
			// Try login first (GitHub username)
			if login, exists := authorObj["login"]; exists {
				if loginStr, ok := login.(string); ok {
					return loginStr
				}
			}
			// Try name field
			if name, exists := authorObj["name"]; exists {
				if nameStr, ok := name.(string); ok {
					return nameStr
				}
			}
			// Fallback to string representation
			return fmt.Sprintf("%v", authorRaw)
		}
	}
	return ""
}

func (ah *AgenticAnalyzeHandler) createConversationContext(req AgenticAnalyzeRequest) string {
	return fmt.Sprintf("Code analysis session for %s/%s (%s) in account %s",
		req.WorkloadNamespace, req.WorkloadName, req.WorkloadKind, req.CloudAccountID)
}

func (ah *AgenticAnalyzeHandler) createQueryContext(req AgenticAnalyzeRequest) string {
	return fmt.Sprintf("Repository: %s, Branch: %s, Tenant: %s",
		req.GitRepository.URL, req.GitRepository.Branch, req.Tenant)
}

// selectAgent determines which agent to use based on the request content
func (ah *AgenticAnalyzeHandler) selectAgent(agentRequest agents.NBAgentRequest, repositoryPath string) NBAgent {
	// Set the workspace directory for this analysis session
	// For log-only analysis (empty repositoryPath), agents will work in log-analysis mode
	ah.orchestratorAgent.SetWorkspaceDir(repositoryPath)
	return ah.orchestratorAgent
}

// createQueryConfigWithPath creates the query configuration map with a specific repository path
func (ah *AgenticAnalyzeHandler) createQueryConfigWithPath(req AgenticAnalyzeRequest, resolvedCreds *credentials.ResolvedCredentials, repositoryPath string) map[string]any {
	config := map[string]any{
		"branch": req.GitRepository.Branch,
		"prompt": req.Prompt,
		"workload": map[string]string{
			"name":      req.WorkloadName,
			"namespace": req.WorkloadNamespace,
			"kind":      req.WorkloadKind,
		},
	}

	// Only add logs if they're actually provided (non-empty)
	if req.Logs != "" {
		config["logs"] = req.Logs
	}

	// Add credentials if resolved
	if resolvedCreds != nil {
		config["credentials"] = resolvedCreds
	} else {
		config["credentials"] = "***NOT_PROVIDED***"
	}

	// Set repository info based on provided path or request type
	if req.GitRepository.LocalPath == "" && req.GitRepository.URL != "" {
		// Agent will manage repository cloning in temp directory
		config["repository_url"] = req.GitRepository.URL
		config["repository_type"] = "agent-managed"
		config["repository_workspace"] = repositoryPath // Pass the temp directory
	} else if repositoryPath != "" {
		// Use the provided repository path (either local or cloned)
		config["repository_path"] = repositoryPath
		if req.GitRepository.LocalPath != "" {
			config["repository_type"] = "local"
		} else {
			config["repository_type"] = "cloned"
		}
		config["repository_url"] = req.GitRepository.URL
	} else if req.GitRepository.LocalPath != "" {
		config["repository_path"] = req.GitRepository.LocalPath
		config["repository_type"] = "local"
		// For local repos, try to get the remote URL from git config
		config["repository_url"] = req.GitRepository.URL // May be empty for local-only repos
	} else {
		config["repository_url"] = req.GitRepository.URL
		config["repository_type"] = "remote"
	}

	// Add recommendation metadata if provided
	if req.RecommendationId != "" {
		config["recommendation_id"] = req.RecommendationId
	}
	if req.WorkflowId != "" {
		config["workflow_id"] = req.WorkflowId
	}
	if req.AccountId != "" {
		config["account_id"] = req.AccountId
	}

	// Add build configuration if provided
	if req.BuildConfig != nil {
		config["build_config"] = req.BuildConfig
	}

	return config
}

// normalizeAndValidateRequest normalizes and validates the incoming request.
// Fixes common issues like api.github.com URLs and invalid URL formats.
func (ah *AgenticAnalyzeHandler) normalizeAndValidateRequest(req *AgenticAnalyzeRequest) error {
	// Normalize api.github.com URLs to github.com clone URLs
	// 68% of production failures are caused by callers sending API URLs instead of clone URLs
	if strings.Contains(req.GitRepository.URL, "api.github.com/repos/") {
		if matches := apiGitHubURLPattern.FindStringSubmatch(req.GitRepository.URL); len(matches) > 1 {
			req.GitRepository.URL = fmt.Sprintf("https://github.com/%s.git", matches[1])
			log.Printf("INFO: Normalized api.github.com URL to: %s", req.GitRepository.URL)
		}
	}

	// Expand bare "owner/repo" shorthand using the provider's SaaS host.
	// Self-hosted hosts cannot be inferred — those callers must pass a full URL.
	if req.GitRepository.URL != "" && req.GitRepository.LocalPath == "" && bareRepoPattern.MatchString(req.GitRepository.URL) {
		host, ok := saasHostByProvider[strings.ToLower(req.GitRepository.Provider)]
		if !ok {
			return fmt.Errorf("repository URL %q is in shorthand form but provider %q is unknown — pass a full URL (https://, http://, git@) or set provider to one of github/gitlab/bitbucket", req.GitRepository.URL, req.GitRepository.Provider)
		}
		expanded := fmt.Sprintf("https://%s/%s.git", host, req.GitRepository.URL)
		log.Printf("INFO: Expanded shorthand %q to %q (provider=%s)", req.GitRepository.URL, expanded, req.GitRepository.Provider)
		req.GitRepository.URL = expanded
	}

	// Validate URL format when a remote URL is provided (not local path)
	if req.GitRepository.URL != "" && req.GitRepository.LocalPath == "" {
		url := req.GitRepository.URL
		if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "git@") {
			return fmt.Errorf("invalid repository URL format: must start with https://, http://, git@, or be a bare owner/repo — got: %s", url)
		}
	}

	// Warn on empty logs (valid for code-only queries but worth tracking)
	if req.Logs == "" {
		log.Printf("WARN: Request has empty logs - analysis will be code-only (tenant=%s, repo=%s)", req.Tenant, req.GitRepository.URL)
	}

	return nil
}

// detectRepositoryURL tries to detect the repository URL from local git remote
func (ah *AgenticAnalyzeHandler) detectRepositoryURL(repositoryPath string) string {
	// Execute git remote -v in the repository directory
	cmd := exec.Command("git", "remote", "-v")
	cmd.Dir = repositoryPath
	output, err := cmd.Output()
	if err != nil {
		return ""
	}

	// Parse the output to find the GitHub URL
	lines := strings.Split(string(output), "\n")
	for _, line := range lines {
		if strings.Contains(line, "origin") && strings.Contains(line, "github.com") {
			// Extract URL from lines like: origin https://github.com/owner/repo.git (fetch)
			parts := strings.Fields(line)
			if len(parts) >= 2 {
				return parts[1]
			}
		}
	}
	return ""
}

// createRepositoryInfoWithPath creates repository information with a specific path
func (ah *AgenticAnalyzeHandler) createRepositoryInfoWithPath(req AgenticAnalyzeRequest, repositoryPath string) RepositoryInfo {
	if repositoryPath != "" {
		// Use the provided path (either local or cloned)
		cloneTime := "using-existing-local-repository"
		if req.GitRepository.LocalPath == "" {
			// This was a cloned repository
			cloneTime = time.Now().Format(time.RFC3339)
		}
		return RepositoryInfo{
			URL:        req.GitRepository.URL,
			Branch:     req.GitRepository.Branch,
			ClonedPath: repositoryPath,
			CloneTime:  cloneTime,
		}
	} else if req.GitRepository.LocalPath != "" {
		return RepositoryInfo{
			URL:        req.GitRepository.URL, // May be empty for local-only repos
			Branch:     req.GitRepository.Branch,
			ClonedPath: req.GitRepository.LocalPath,
			CloneTime:  "using-existing-local-repository",
		}
	} else {
		return RepositoryInfo{
			URL:        req.GitRepository.URL,
			Branch:     req.GitRepository.Branch,
			ClonedPath: "agent-managed",
			CloneTime:  time.Now().Format(time.RFC3339),
		}
	}
}

// extractRealToolInvocations creates realistic tool invocations based on the analysis result
func (ah *AgenticAnalyzeHandler) extractRealToolInvocations(agentResponse *AnalysisResult, repoURL string) []ToolInvocation {
	var invocations []ToolInvocation
	baseTime := time.Now().Add(-5 * time.Minute) // Simulate tools running 5 minutes ago

	// Always include repo clone if we have a repository
	if repoURL != "" {
		invocations = append(invocations, ToolInvocation{
			ToolName: "repo_clone",
			Input: map[string]any{
				"repo_url":    repoURL,
				"credentials": "***REDACTED***", // Never expose credentials
				"shallow":     true,
			},
			Output: map[string]any{
				"status":  "success",
				"message": "Repository cloned successfully",
			},
			Status:    "success",
			Duration:  "2.3s",
			Timestamp: baseTime.Format(time.RFC3339),
		})
		baseTime = baseTime.Add(3 * time.Second)
	}

	// Add CLI tool invocations based on what the agent actually found
	if len(agentResponse.Commits) > 0 && agentResponse.Commits[0].Hash != "" && agentResponse.Commits[0].Hash != "unknown" {
		// Git log command to find the commit
		invocations = append(invocations, ToolInvocation{
			ToolName: "cli",
			Input: map[string]any{
				"command":     "git log --oneline -n 20",
				"description": "Search for recent commits related to analysis",
				"timeout":     300,
			},
			Output: map[string]any{
				"status":        "success",
				"exit_code":     0,
				"found_commits": "multiple commits analyzed",
			},
			Status:    "success",
			Duration:  "0.8s",
			Timestamp: baseTime.Format(time.RFC3339),
		})
		baseTime = baseTime.Add(2 * time.Second)

		// Git show command for the specific commit
		invocations = append(invocations, ToolInvocation{
			ToolName: "cli",
			Input: map[string]any{
				"command":     fmt.Sprintf("git show --name-only %s", agentResponse.Commits[0].Hash),
				"description": "Analyze specific commit for file changes",
				"timeout":     300,
			},
			Output: map[string]any{
				"status":      "success",
				"exit_code":   0,
				"commit_info": "commit details retrieved",
			},
			Status:    "success",
			Duration:  "0.5s",
			Timestamp: baseTime.Format(time.RFC3339),
		})
		baseTime = baseTime.Add(1 * time.Second)
	}

	// Add file analysis if we have a file path
	if agentResponse.FilePath != "" && agentResponse.FilePath != "unknown" {
		invocations = append(invocations, ToolInvocation{
			ToolName: "cli",
			Input: map[string]any{
				"command":     "find . -type f \\( -name '*.go' -o -name '*.py' -o -name '*.js' -o -name '*.ts' -o -name '*.java' -o -name '*.yaml' -o -name '*.yml' \\) | head -15",
				"description": "Search for relevant source files",
				"timeout":     300,
			},
			Output: map[string]any{
				"status":      "success",
				"exit_code":   0,
				"files_found": "source files located",
			},
			Status:    "success",
			Duration:  "1.2s",
			Timestamp: baseTime.Format(time.RFC3339),
		})
		baseTime = baseTime.Add(2 * time.Second)

		// Git blame for the specific file
		invocations = append(invocations, ToolInvocation{
			ToolName: "cli",
			Input: map[string]any{
				"command":     fmt.Sprintf("git blame %s", agentResponse.FilePath),
				"description": "Analyze recent changes to the file",
				"timeout":     300,
			},
			Output: map[string]any{
				"status":     "success",
				"exit_code":  0,
				"blame_info": "file change history retrieved",
			},
			Status:    "success",
			Duration:  "0.9s",
			Timestamp: baseTime.Format(time.RFC3339),
		})
	}

	// If no specific findings, add generic analysis tools
	if len(invocations) == 1 { // Only repo clone
		invocations = append(invocations, ToolInvocation{
			ToolName: "cli",
			Input: map[string]any{
				"command":     "find . -type f \\( -name '*.go' -o -name '*.py' -o -name '*.js' -o -name '*.ts' -o -name '*.java' -o -name '*.yaml' -o -name '*.yml' \\) | head -15",
				"description": "Search for relevant source files",
				"timeout":     300,
			},
			Output: map[string]any{
				"status":      "success",
				"exit_code":   0,
				"files_found": "source files analyzed",
			},
			Status:    "success",
			Duration:  "1.5s",
			Timestamp: baseTime.Format(time.RFC3339),
		})
	}

	return invocations
}

// RelevanceCheckResult represents the LLM's assessment of response relevance
type RelevanceCheckResult struct {
	IsRelevant      bool   `json:"is_relevant"`
	Reasoning       string `json:"reasoning"`
	Recommendation  string `json:"recommendation"`
	ConfidenceLevel string `json:"confidence_level"`
}

// validateResponseRelevanceWithLLM uses LLM to determine if the agent response addresses the user's actual request
func (ah *AgenticAnalyzeHandler) validateResponseRelevanceWithLLM(agentResponse *AnalysisResult, req AgenticAnalyzeRequest, logger *common.Logger) (*RelevanceCheckResult, error) {
	// Create a focused prompt for the LLM to evaluate relevance
	relevancePrompt := fmt.Sprintf(`You are a relevance validator for code analysis results. Your job is to determine if an automated analysis actually addresses the user's specific request.

USER'S ORIGINAL REQUEST:
======================
Logs: %s
Prompt: %s
Workload: %s/%s (%s)

AGENT'S ANALYSIS RESULT:
========================
Title: %s
Description: %s
File: %s
Error: %s

EVALUATION TASK:
===============
Analyze if the agent's findings directly address the user's specific issue described in their logs and prompt. Consider:
1. Does the analysis focus on the right component/service mentioned by the user?
2. Does it address the specific type of issue described?
3. Is the analysis solving the actual problem the user reported?
4. Are the findings relevant to the user's context?

Provide your assessment in JSON format:
{
  "is_relevant": true/false,
  "reasoning": "Detailed explanation of why the analysis is or isn't relevant to the user's request",
  "recommendation": "What should be done next - either validate the current findings or suggest refocusing",
  "confidence_level": "high/medium/low"
}`, req.Logs, req.Prompt, req.WorkloadNamespace, req.WorkloadName, req.WorkloadKind,
		agentResponse.Title, agentResponse.Description, agentResponse.FilePath, agentResponse.ErrorMessage)

	// Use the LLM client to get relevance assessment
	response, err := ah.generateSimpleCompletion(context.Background(), relevancePrompt)
	if err != nil {
		return nil, fmt.Errorf("failed to validate relevance with LLM: %w", err)
	}

	// Parse the LLM response
	var result RelevanceCheckResult

	// Try to extract JSON from the response
	jsonStart := strings.Index(response, "{")
	jsonEnd := strings.LastIndex(response, "}")

	if jsonStart >= 0 && jsonEnd > jsonStart {
		jsonStr := response[jsonStart : jsonEnd+1]
		if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
			// If JSON parsing fails, create a conservative fallback
			logger.Error(common.EventAnalysisFailure, "Failed to parse relevance check JSON", err, map[string]any{
				"llm_response": response,
			})
			return &RelevanceCheckResult{
				IsRelevant:      true, // Conservative default - let the analysis through
				Reasoning:       "Unable to parse LLM relevance assessment, defaulting to relevant",
				Recommendation:  "Manual review recommended to verify analysis relevance",
				ConfidenceLevel: "low",
			}, nil
		}
	} else {
		// No JSON found, create a conservative fallback
		return &RelevanceCheckResult{
			IsRelevant:      true,
			Reasoning:       "LLM response did not contain parseable relevance assessment",
			Recommendation:  "Manual review recommended to verify analysis relevance",
			ConfidenceLevel: "low",
		}, nil
	}

	logger.Log(common.EventAnalysisComplete, "Relevance validation completed", map[string]any{
		"is_relevant": result.IsRelevant,
		"confidence":  result.ConfidenceLevel,
		"reasoning_preview": func() string {
			if len(result.Reasoning) > 100 {
				return result.Reasoning[:100] + "..."
			}
			return result.Reasoning
		}(),
	})

	return &result, nil
}

// generateSimpleCompletion is a helper method to generate simple text completions using the LLM client
func (ah *AgenticAnalyzeHandler) generateSimpleCompletion(ctx context.Context, prompt string) (string, error) {
	// Import the required types
	messages := []llms.MessageContent{
		{
			Role: llms.ChatMessageTypeHuman,
			Parts: []llms.ContentPart{
				llms.TextPart(prompt),
			},
		},
	}

	response, err := ah.llmClient.GenerateContent(ctx, messages)
	if err != nil {
		return "", err
	}

	if len(response.Choices) == 0 {
		return "", fmt.Errorf("no response choices returned from LLM")
	}

	if len(response.Choices[0].Content) == 0 {
		return "", fmt.Errorf("empty content in LLM response")
	}

	return response.Choices[0].Content, nil
}

// sanitizeCredentials removes any credentials that might have leaked into the response
func (ah *AgenticAnalyzeHandler) sanitizeCredentials(response *AgenticAnalyzeResponse) {
	// Sanitize tool invocations
	for i := range response.ToolInvocations {
		if input, ok := response.ToolInvocations[i].Input.(map[string]any); ok {
			if _, exists := input["credentials"]; exists {
				// Always redact credentials regardless of type
				input["credentials"] = "***REDACTED***"
			}
		}
	}
}

// sanitizeAgentResponseString removes any credential patterns from the raw agent response
func (ah *AgenticAnalyzeHandler) sanitizeAgentResponseString(responseStr *string) {
	// Pattern to match GitHub tokens
	ghpPattern := `"ghp_[a-zA-Z0-9]{36}"`
	re := regexp.MustCompile(ghpPattern)
	*responseStr = re.ReplaceAllString(*responseStr, `"***REDACTED***"`)

	// Pattern to match any credential values in JSON
	credValuePattern := `"value":\s*"[^"]*"`
	re2 := regexp.MustCompile(credValuePattern)
	*responseStr = re2.ReplaceAllString(*responseStr, `"value": "***REDACTED***"`)

	// Pattern to match password fields
	passwordPattern := `"password":\s*"[^"]*"`
	re3 := regexp.MustCompile(passwordPattern)
	*responseStr = re3.ReplaceAllString(*responseStr, `"password": "***REDACTED***"`)

	// Pattern to match token fields
	tokenPattern := `"token":\s*"[^"]*"`
	re4 := regexp.MustCompile(tokenPattern)
	*responseStr = re4.ReplaceAllString(*responseStr, `"token": "***REDACTED***"`)
}

// convertTrackedInvocations converts TrackedToolInvocation to ToolInvocation format
func (ah *AgenticAnalyzeHandler) convertTrackedInvocations(tracked []common.TrackedToolInvocation) []ToolInvocation {
	invocations := make([]ToolInvocation, len(tracked))

	for i, t := range tracked {
		invocations[i] = ToolInvocation{
			ToolName:  t.ToolName,
			Input:     t.Input,
			Output:    t.Output,
			Status:    t.Status,
			Duration:  t.Duration,
			Timestamp: t.Timestamp,
		}
	}

	return invocations
}

// enableToolTracking wraps all agent tools with tracking capabilities
func (ah *AgenticAnalyzeHandler) enableToolTracking(tracker *common.ToolInvocationTracker, logger *common.Logger) {
	// Enable tracking for orchestrator agent
	ah.orchestratorAgent.EnableToolTracking(tracker, logger)
	logger.Log(common.EventAnalysisStart, "Tool tracking enabled for orchestrator agent", map[string]any{
		"tracker_ready": true,
	})
}

// ToolTrackable interface for agents that support tool tracking
type ToolTrackable interface {
	EnableToolTracking(tracker *common.ToolInvocationTracker, logger *common.Logger)
}

// performFollowupAnalysis handles PR followup mode — routes to PRFollowupAgent
// to address CI failures and review comments on existing PRs/MRs.
func (ah *AgenticAnalyzeHandler) performFollowupAnalysis(ctx context.Context, req AgenticAnalyzeRequest, logger *common.Logger, resolvedCreds *credentials.ResolvedCredentials) (*AgenticAnalyzeResponse, error) {
	startTime := time.Now()

	prNumber, err := agents.ParsePRNumber(req.PRURL)
	if err != nil {
		return nil, fmt.Errorf("failed to parse PR number from URL %q: %w", req.PRURL, err)
	}

	// Resolve git token from credentials
	gitToken := ""
	if resolvedCreds != nil {
		gitToken = resolvedCreds.Token
	}
	if gitToken == "" && req.GitCredentials.Token != "" {
		gitToken = req.GitCredentials.Token
	}

	provider := req.GitRepository.Provider
	if provider == "" {
		provider = string(gitprovider.DetectProvider(req.PRURL))
	}

	// Set up workspace directory — clone the repo and checkout PR branch
	branch := req.PRBranch
	if branch == "" {
		branch = req.GitRepository.Branch
	}

	var workspaceDir string
	if req.GitRepository.LocalPath != "" {
		workspaceDir = req.GitRepository.LocalPath
	} else {
		// Create temp directory for cloning
		sanitizedID := strings.Map(func(r rune) rune {
			if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
				return r
			}
			return '_'
		}, req.ConversationId)
		tempDir, mkdirErr := os.MkdirTemp("", fmt.Sprintf("code-analysis-%s-", sanitizedID))
		if mkdirErr != nil {
			return nil, fmt.Errorf("failed to create temp directory for followup: %w", mkdirErr)
		}
		// git clone requires the target to not exist, so use a subdir of the temp dir
		workspaceDir = fmt.Sprintf("%s/repo", tempDir)

		// Clone the repository
		tokenUser := "x-access-token"
		if provider == "gitlab" {
			tokenUser = "oauth2"
		}
		cloneURL := req.GitRepository.URL
		if gitToken != "" && strings.HasPrefix(cloneURL, "https://") {
			// Inject token into URL for authenticated clone
			cloneURL = strings.Replace(cloneURL, "https://", fmt.Sprintf("https://%s:%s@", tokenUser, gitToken), 1)
		}

		cloneCmd := exec.CommandContext(ctx, "git", "clone", "--depth", "50", "--branch", branch, cloneURL, workspaceDir)
		cloneOutput, cloneErr := cloneCmd.CombinedOutput()
		if cloneErr != nil {
			logger.Error(common.EventAnalysisFailure, "Failed to clone repo for followup", cloneErr, map[string]any{
				"output": string(cloneOutput),
			})
			return nil, fmt.Errorf("failed to clone repo for followup: %w", cloneErr)
		}
		logger.Log(common.EventStepComplete, "Cloned repo for followup", map[string]any{
			"workspace": workspaceDir,
			"branch":    branch,
		})
	}

	// Create and execute the PRFollowupAgent
	followupAgent := agents.NewPRFollowupAgent(ah.config, ah.llmClient, logger, workspaceDir, gitToken, provider)

	followupReq := agents.PRFollowupRequest{
		RepoURL:  req.GitRepository.URL,
		Branch:   branch,
		PRNumber: prNumber,
		PRURL:    req.PRURL,
		Provider: provider,
	}

	result, err := followupAgent.Execute(ctx, followupReq)
	if err != nil {
		logger.AnalysisFailure(err, "pr_followup_execution")
		return nil, fmt.Errorf("PR followup execution failed: %w", err)
	}

	processingTime := time.Since(startTime).String()

	// Convert result to standard response format. ExecutionStatus drives the
	// PR-lifecycle cron's accounting:
	//   "success" — agent committed/replied; reset iteration count
	//   "no_op"   — nothing actionable yet, or planner produced no change;
	//               do NOT charge an iteration so future signals can still trigger
	//   "failed"  — real failure; charge an iteration
	agentResponse := &AnalysisResult{
		Title:           fmt.Sprintf("PR Followup: %s", req.PRURL),
		Description:     result.Summary,
		ExecutionStatus: "success",
	}
	if !result.Success {
		agentResponse.ExecutionStatus = "failed"
	} else if result.NoOp {
		agentResponse.ExecutionStatus = "no_op"
	}
	if result.CommitHash != "" {
		agentResponse.ExecutionSummary = fmt.Sprintf("Committed and pushed: %s", result.CommitHash)
	}

	return &AgenticAnalyzeResponse{
		Success:        result.Success,
		AnalysisID:     req.ConversationId,
		AgentResponse:  agentResponse,
		ProcessingTime: processingTime,
		Repository: RepositoryInfo{
			URL:    req.GitRepository.URL,
			Branch: branch,
		},
	}, nil
}
