/**
 * @description STDM query service for the agentforce-optimize Claude Code skill.
 *              Queries the Session Trace Data Model (STDM) in Data Cloud to retrieve
 *              session traces, conversation turns, messages, and steps for issue analysis.
 *
 *              Deployed once per org by the agentforce-optimize skill (Phase 1 setup).
 *              All public methods accept dataSpaceName so no Data Space is hardcoded.
 *
 * Methods:
 *   findSessions(dataSpaceName, startIso, endIso, maxRows)            → JSON List<SessionSummary>
 *   findSessions(dataSpaceName, startIso, endIso, maxRows, agentName) → JSON List<SessionSummary>
 *   getConversationDetails(dataSpaceName, sessionId)                  → JSON ConversationData
 *   getMultipleConversationDetails(dataSpaceName, sessionIds)         → JSON List<ConversationData>
 *   getLlmStepDetails(dataSpaceName, stepIds)                         → JSON List<LlmStepDetail>
 *   getMomentInsights(dataSpaceName, sessionIds)                      → JSON List<SessionInsights>
 *   getAggregatedMetrics(dataSpaceName, startIso, endIso, maxRows, agentName) → JSON AggregatedMetrics
 *   runObservabilityQuery(List<ObservabilityInput>)                         → List<ObservabilityOutput> (@InvocableMethod)
 */
public with sharing class AgentforceOptimizeService {

    // =========================================================================
    // Output wrappers
    // =========================================================================

    /** Lightweight session record returned by findSessions(). */
    public class SessionSummary {
        public String  session_id;
        public String  start_time;
        public String  end_time;
        public String  channel;
        public Long    duration_ms;
        /** How the session ended: e.g. USER_ENDED, AGENT_ENDED (null = in progress or not recorded) */
        public String  end_type;
    }

    /** A single user/agent message within a turn. */
    public class MessageData {
        public String message_id;
        /** 'Input' (user) or 'Output' (agent) — raw STDM value */
        public String message_type;
        public String text;
        public String sent_at;
    }

    /**
     * A single internal step within a turn.
     * All issue-detection fields are included:
     *   - error          → non-null means ACTION_STEP failure (P1)
     *   - pre_vars / post_vars → null delta means variable not captured (P2)
     *   - duration_ms > 10 000 → slow action (P3)
     *   - generation_id  → non-null on LLM_STEP; use getLlmStepDetails() to get the prompt
     */
    public class StepData {
        public String step_id;
        /** TOPIC_STEP | LLM_STEP | ACTION_STEP | SESSION_END | TRUST_GUARDRAILS_STEP */
        public String step_type;
        public String name;
        public String start_time;
        public String end_time;
        public Long   duration_ms;
        /** Raw input to the step (JSON for ACTION_STEP; Python dict string for LLM_STEP) */
        public String input;
        /** Raw output from the step (JSON for ACTION_STEP; Python dict string for LLM_STEP) */
        public String output;
        /** Non-null indicates the step threw an error (only ACTION_STEP counts toward action_error_count) */
        public String error;
        /** Variable snapshot before this step (null when NOT_SET) */
        public String pre_vars;
        /** Variable snapshot after this step (null when NOT_SET) */
        public String post_vars;
        /** GenAiGeneration ID — non-null on LLM_STEP; pass to getLlmStepDetails() for full prompt/response */
        public String generation_id;
        /** GenAiGatewayRequest ID — non-null on LLM_STEP; links to raw gateway request */
        public String gateway_request_id;
    }

    /**
     * One conversational turn (AiAgentInteraction of type TURN).
     * Contains all messages and steps for that turn.
     */
    public class TurnData {
        public String interaction_id;
        /** Topic API name — null/mismatch signals a misroute (P1) */
        public String topic;
        public String start_time;
        public String end_time;
        public Long   duration_ms;
        /** Telemetry trace ID for distributed tracing correlation */
        public String telemetry_trace_id;
        public List<MessageData> messages;
        public List<StepData>    steps;

        public TurnData() {
            messages = new List<MessageData>();
            steps    = new List<StepData>();
        }
    }

    /**
     * Full conversation for one session: session header + ordered turns.
     * turn_count and action_error_count are pre-computed for quick triage.
     */
    public class ConversationData {
        public String  session_id;
        public String  start_time;
        public String  end_time;
        public String  channel;
        public Long    duration_ms;
        /** How the session ended (null = in progress or not recorded by Data Cloud) */
        public String  end_type;
        /** Session-level variable snapshot from ssot__VariableText__c (null when absent) */
        public String  session_variables;
        public Integer turn_count        = 0;
        public Integer action_error_count = 0;
        public List<TurnData> turns;

        public ConversationData() {
            turns = new List<TurnData>();
        }
    }

    /**
     * LLM step detail retrieved from Einstein Audit & Feedback DMOs.
     * Obtained by joining AiAgentInteractionStep with GenAIGeneration and GenAIGatewayRequest.
     */
    public class LlmStepDetail {
        public String step_id;
        public String interaction_id;
        public String step_name;
        /** Full prompt text from GenAIGatewayRequest__dlm.prompt__c */
        public String prompt;
        /** LLM response text from GenAIGeneration__dlm.responseText__c */
        public String llm_response;
        public String generation_id;
        public String gateway_request_id;
    }

    /** A single intent moment within a session (from AiAgentMoment DMO). */
    public class MomentData {
        public String  moment_id;
        public String  session_id;
        public String  start_time;
        public String  end_time;
        public Long    duration_ms;
        public String  request_summary;
        public String  response_summary;
        public String  agent_api_name;
        public String  agent_version;
        /** Quality score 1-5 from AiAgentTagAssociation → AiAgentTag.Value */
        public Integer quality_score;
        /** LLM-generated reasoning for the quality score */
        public String  quality_reasoning;
        public MomentData() {}
    }

    /** RAG quality metrics from the AiRetrieverQualityMetric DMO. */
    public class RetrieverMetricData {
        public String  metric_id;
        public String  gateway_request_id;
        public String  retriever_request_id;
        public String  retriever_api_name;
        public String  user_utterance;
        public Decimal faithfulness;
        public Decimal answer_relevance;
        public Decimal context_precision;
    }

    /** Per-session insights rollup (moments + retriever metrics). */
    public class SessionInsights {
        public String  session_id;
        public String  start_time;
        public String  end_time;
        public String  end_type;
        public Long    duration_ms;
        public Integer turn_count;
        public Integer moment_count;
        public Decimal avg_quality_score;
        public Integer action_error_count;
        public List<MomentData> moments;
        public List<RetrieverMetricData> retriever_metrics;
        public String  debug_message;
        public SessionInsights() {
            moments = new List<MomentData>();
            retriever_metrics = new List<RetrieverMetricData>();
        }
    }

    /** Aggregated metrics across multiple sessions. */
    public class AggregatedMetrics {
        public Integer total_sessions;
        public Integer total_moments;
        public Integer total_turns;
        public Decimal avg_quality_score;
        public Decimal avg_session_duration_sec;
        public Map<String, Integer> end_type_counts;
        public Map<String, Integer> top_intents;
        public Map<String, Integer> quality_distribution;
        public Decimal abandonment_rate;
        public Decimal escalation_rate;
        public Decimal deflection_rate;
        public Decimal avg_faithfulness;
        public Decimal avg_answer_relevance;
        public Decimal avg_context_precision;
        public List<String> unavailable_dmos;
    }

    // =========================================================================
    // Public API
    // =========================================================================

    /**
     * Find recent sessions within a date range, optionally filtered to a specific agent.
     *
     * Agent filtering tries two strategies in order:
     *   1. Direct: ssot__AiAgentApiName__c = agentApiName on the participant DMO (no SOQL needed)
     *   2. Fallback: GenAiPlannerDefinition (SOQL) → ssot__ParticipantId__c IN plannerIds
     *      Both 15-char and 18-char ID formats are included to handle DMO inconsistency.
     *
     * If both strategies return empty, the query falls back to all sessions.
     *
     * @param dataSpaceName  Data Cloud Data Space API name (discovered in Phase 0)
     * @param startIso       ISO 8601 UTC start, e.g. '2025-03-01T00:00:00.000Z'
     * @param endIso         ISO 8601 UTC end
     * @param maxRows        Maximum sessions to return (e.g. 20)
     * @param agentApiName   Agent display name / MasterLabel to filter by (null = all agents)
     * @return JSON-serialized List<SessionSummary>
     */
    public static String findSessions(String dataSpaceName, String startIso, String endIso, Integer maxRows, String agentApiName) {
        // Step 1: Find sessions that have actual TURN interactions (skip empty preview sessions).
        // Empty sessions (sf agent preview, builder pings) create AiAgentSession + SESSION_END
        // interaction records but never TURN records. Querying for TURN directly ensures we only
        // return sessions with real conversation data.
        String turnSessionSql =
              'SELECT DISTINCT ssot__AiAgentSessionId__c '
            + 'FROM "ssot__AiAgentInteraction__dlm" '
            + 'WHERE ssot__AiAgentInteractionType__c = \'TURN\' '
            + '  AND ssot__StartTimestamp__c >= \'' + startIso + '\' '
            + '  AND ssot__StartTimestamp__c <= \'' + endIso   + '\'';

        ConnectApi.CdpQueryOutputV2 turnResult = runQuery(turnSessionSql, dataSpaceName);
        Set<String> sessionsWithTurns = new Set<String>();
        if (turnResult != null && turnResult.data != null) {
            for (ConnectApi.CdpQueryV2Row row : turnResult.data) {
                String sid = col(row.rowData, 0);
                if (sid != null) sessionsWithTurns.add(sid);
            }
        }
        System.debug(LoggingLevel.DEBUG, 'Sessions with turns in date range: ' + sessionsWithTurns.size());

        // Step 2: Build agent filter (optional)
        String sessionFilter = '';
        if (String.isNotBlank(agentApiName)) {
            // Strategy 1: filter directly by ssot__AiAgentApiName__c (simplest, no SOQL)
            String partSqlByName =
                  'SELECT ssot__AiAgentSessionId__c '
                + 'FROM "ssot__AiAgentSessionParticipant__dlm" '
                + 'WHERE ssot__AiAgentApiName__c = \'' + String.escapeSingleQuotes(agentApiName) + '\'';
            ConnectApi.CdpQueryOutputV2 nameResult = runQuery(partSqlByName, dataSpaceName);
            List<String> sessionIds = extractSessionIds(nameResult);

            if (!sessionIds.isEmpty()) {
                sessionFilter = '  AND ssot__Id__c IN (\'' + String.join(sessionIds, '\',\'') + '\') ';
                System.debug(LoggingLevel.DEBUG, 'Agent filter (AiAgentApiName): '
                    + sessionIds.size() + ' session(s) for agent: ' + agentApiName);
            } else {
                // Strategy 2: GenAiPlannerDefinition SOQL → ssot__ParticipantId__c
                System.debug(LoggingLevel.DEBUG,
                    'AiAgentApiName filter returned no sessions; trying GenAiPlannerDefinition fallback');
                List<String> plannerIds = resolvePlannerIds(agentApiName);
                if (!plannerIds.isEmpty()) {
                    String pInClause = '(\'' + String.join(plannerIds, '\',\'') + '\')';
                    String partSqlById =
                          'SELECT ssot__AiAgentSessionId__c '
                        + 'FROM "ssot__AiAgentSessionParticipant__dlm" '
                        + 'WHERE ssot__ParticipantId__c IN ' + pInClause;
                    ConnectApi.CdpQueryOutputV2 idResult = runQuery(partSqlById, dataSpaceName);
                    List<String> sessionIds2 = extractSessionIds(idResult);
                    if (!sessionIds2.isEmpty()) {
                        sessionFilter = '  AND ssot__Id__c IN (\'' + String.join(sessionIds2, '\',\'') + '\') ';
                        System.debug(LoggingLevel.DEBUG, 'Agent filter (PlannerIds): '
                            + plannerIds.size() + ' planner(s), ' + sessionIds2.size() + ' session(s)');
                    } else {
                        System.debug(LoggingLevel.WARN,
                            'No sessions found for agent: ' + agentApiName + ' — returning all sessions');
                    }
                } else {
                    System.debug(LoggingLevel.WARN,
                        'Agent not found: ' + agentApiName + ' — returning sessions for all agents');
                }
            }
        }

        // Step 3: Query sessions, preferring those with actual turns
        String turnFilter = '';
        if (!sessionsWithTurns.isEmpty()) {
            List<String> turnList = new List<String>(sessionsWithTurns);
            turnFilter = '  AND ssot__Id__c IN (\'' + String.join(turnList, '\',\'') + '\') ';
        }

        String sql =
              'SELECT ssot__Id__c, ssot__StartTimestamp__c, ssot__EndTimestamp__c, '
            + '       ssot__AiAgentChannelType__c, ssot__AiAgentSessionEndType__c '
            + 'FROM "ssot__AiAgentSession__dlm" '
            + 'WHERE ssot__StartTimestamp__c >= \'' + startIso + '\' '
            + '  AND ssot__StartTimestamp__c <= \'' + endIso   + '\' '
            + sessionFilter
            + turnFilter
            + 'ORDER BY ssot__StartTimestamp__c DESC '
            + 'LIMIT ' + maxRows;

        ConnectApi.CdpQueryOutputV2 result = runQuery(sql, dataSpaceName);
        List<SessionSummary> sessions = new List<SessionSummary>();

        if (result != null && result.data != null) {
            for (ConnectApi.CdpQueryV2Row row : result.data) {
                SessionSummary s = new SessionSummary();
                s.session_id  = col(row.rowData, 0);
                s.start_time  = col(row.rowData, 1);
                s.end_time    = col(row.rowData, 2);
                s.channel     = col(row.rowData, 3);
                s.end_type    = notSet(col(row.rowData, 4));
                s.duration_ms = durationMs(s.start_time, s.end_time);
                sessions.add(s);
            }
        }
        return JSON.serialize(sessions);
    }

    /**
     * Overload that queries sessions for all agents (no agent filter).
     * Kept for backwards compatibility; prefer the 5-argument version.
     */
    public static String findSessions(String dataSpaceName, String startIso, String endIso, Integer maxRows) {
        return findSessions(dataSpaceName, startIso, endIso, maxRows, null);
    }

    /**
     * Retrieve full conversation details for a single session.
     * Fetches interactions, messages, and steps (with error/variable/generation fields).
     *
     * @param dataSpaceName  Data Cloud Data Space API name
     * @param sessionId      ssot__Id__c of the AiAgentSession
     * @return JSON-serialized ConversationData
     */
    public static String getConversationDetails(String dataSpaceName, String sessionId) {
        if (String.isBlank(sessionId)) return null;

        ConversationData convo = new ConversationData();
        convo.session_id = sessionId;

        // --- Session header ---
        String sessionSql =
              'SELECT ssot__StartTimestamp__c, ssot__EndTimestamp__c, ssot__AiAgentChannelType__c, '
            + '       ssot__AiAgentSessionEndType__c, ssot__VariableText__c '
            + 'FROM "ssot__AiAgentSession__dlm" '
            + 'WHERE ssot__Id__c = \'' + String.escapeSingleQuotes(sessionId) + '\'';

        ConnectApi.CdpQueryOutputV2 sessionResult = runQuery(sessionSql, dataSpaceName);
        if (sessionResult != null && sessionResult.data != null && !sessionResult.data.isEmpty()) {
            List<Object> r = sessionResult.data[0].rowData;
            convo.start_time        = col(r, 0);
            convo.end_time          = col(r, 1);
            convo.channel           = col(r, 2);
            convo.end_type          = notSet(col(r, 3));
            convo.session_variables = notSet(col(r, 4));
            convo.duration_ms       = durationMs(convo.start_time, convo.end_time);
        }

        // --- Interactions (turns) ---
        // ssot__TopicApiName__c included for misroute detection
        // ssot__TelemetryTraceId__c included for distributed tracing
        String interSql =
              'SELECT ssot__Id__c, ssot__TopicApiName__c, ssot__AiAgentInteractionType__c, '
            + '       ssot__StartTimestamp__c, ssot__EndTimestamp__c, ssot__TelemetryTraceId__c '
            + 'FROM "ssot__AiAgentInteraction__dlm" '
            + 'WHERE ssot__AiAgentSessionId__c = \'' + String.escapeSingleQuotes(sessionId) + '\' '
            + 'ORDER BY ssot__StartTimestamp__c';

        ConnectApi.CdpQueryOutputV2 interResult = runQuery(interSql, dataSpaceName);
        if (interResult == null || interResult.data == null || interResult.data.isEmpty()) {
            return JSON.serialize(convo);
        }

        Map<String, TurnData> turnById = new Map<String, TurnData>();
        List<String> turnIds = new List<String>();

        for (ConnectApi.CdpQueryV2Row row : interResult.data) {
            String interType = col(row.rowData, 2);
            // SESSION_END is a meta interaction, not a user turn
            if ('SESSION_END'.equalsIgnoreCase(interType)) continue;

            TurnData t            = new TurnData();
            t.interaction_id      = col(row.rowData, 0);
            t.topic               = col(row.rowData, 1);
            t.start_time          = col(row.rowData, 3);
            t.end_time            = col(row.rowData, 4);
            t.duration_ms         = durationMs(t.start_time, t.end_time);
            t.telemetry_trace_id  = notSet(col(row.rowData, 5));
            turnById.put(t.interaction_id, t);
            turnIds.add(t.interaction_id);
            convo.turns.add(t);
        }
        convo.turn_count = convo.turns.size();

        if (turnIds.isEmpty()) return JSON.serialize(convo);

        String inClause = '(\'' + String.join(turnIds, '\',\'') + '\')';

        // --- Messages ---
        String msgSql =
              'SELECT ssot__Id__c, ssot__AiAgentInteractionId__c, '
            + '       ssot__AiAgentInteractionMessageType__c, ssot__ContentText__c, '
            + '       ssot__MessageSentTimestamp__c '
            + 'FROM "ssot__AiAgentInteractionMessage__dlm" '
            + 'WHERE ssot__AiAgentInteractionId__c IN ' + inClause + ' '
            + 'ORDER BY ssot__MessageSentTimestamp__c';

        ConnectApi.CdpQueryOutputV2 msgResult = runQuery(msgSql, dataSpaceName);
        if (msgResult != null && msgResult.data != null) {
            for (ConnectApi.CdpQueryV2Row row : msgResult.data) {
                TurnData t = turnById.get(col(row.rowData, 1));
                if (t == null) continue;

                MessageData m  = new MessageData();
                m.message_id   = col(row.rowData, 0);
                m.message_type = col(row.rowData, 2); // 'Input' or 'Output'
                m.text         = col(row.rowData, 3);
                m.sent_at      = col(row.rowData, 4);
                t.messages.add(m);
            }
        }

        // Infer message types when ssot__AiAgentInteractionMessageType__c is null.
        // In STDM, messages within a turn alternate: user Input first, then agent Output.
        // If ALL messages in a turn have null type, assign by position (odd=Input, even=Output).
        for (TurnData t : convo.turns) {
            Boolean anyNull = false;
            Boolean allNull = true;
            for (MessageData m : t.messages) {
                if (m.message_type == null) { anyNull = true; }
                else { allNull = false; }
            }
            if (anyNull && allNull) {
                // All null — infer from position: 1st=Input, 2nd=Output, 3rd=Input, ...
                for (Integer i = 0; i < t.messages.size(); i++) {
                    t.messages[i].message_type = (Math.mod(i, 2) == 0) ? 'Input' : 'Output';
                }
            } else if (anyNull) {
                // Mixed — fill gaps by inferring the opposite of the nearest known neighbor
                for (Integer i = 0; i < t.messages.size(); i++) {
                    if (t.messages[i].message_type == null) {
                        // Look at previous message for context
                        if (i > 0 && t.messages[i - 1].message_type != null) {
                            t.messages[i].message_type = 'Input'.equals(t.messages[i - 1].message_type)
                                ? 'Output' : 'Input';
                        } else if (i == 0) {
                            t.messages[i].message_type = 'Input'; // first message is always user
                        }
                    }
                }
            }
        }

        // --- Steps ---
        // All issue-detection fields are selected:
        //   ssot__ErrorMessageText__c        → Action error (P1)
        //   ssot__InputValueText__c          → Wrong action input (P2)
        //   ssot__OutputValueText__c         → Action output / TRUST_GUARDRAILS adherence dict
        //   ssot__PreStepVariableText__c     → Pre-step variable snapshot (P2)
        //   ssot__PostStepVariableText__c    → Post-step variable snapshot (P2)
        //   ssot__EndTimestamp__c            → Step duration for slow action (P3)
        //   ssot__GenerationId__c            → Links to GenAIGeneration__dlm (LLM audit)
        //   ssot__GenAiGatewayRequestId__c   → Links to GenAIGatewayRequest__dlm (prompt text)
        String stepSql =
              'SELECT ssot__Id__c, ssot__AiAgentInteractionId__c, '
            + '       ssot__AiAgentInteractionStepType__c, ssot__Name__c, '
            + '       ssot__StartTimestamp__c, ssot__EndTimestamp__c, '
            + '       ssot__InputValueText__c, ssot__OutputValueText__c, '
            + '       ssot__ErrorMessageText__c, '
            + '       ssot__PreStepVariableText__c, ssot__PostStepVariableText__c, '
            + '       ssot__GenerationId__c, ssot__GenAiGatewayRequestId__c '
            + 'FROM "ssot__AiAgentInteractionStep__dlm" '
            + 'WHERE ssot__AiAgentInteractionId__c IN ' + inClause + ' '
            + 'ORDER BY ssot__StartTimestamp__c';

        ConnectApi.CdpQueryOutputV2 stepResult = runQuery(stepSql, dataSpaceName);
        if (stepResult != null && stepResult.data != null) {
            for (ConnectApi.CdpQueryV2Row row : stepResult.data) {
                TurnData t = turnById.get(col(row.rowData, 1));
                if (t == null) continue;

                StepData s        = new StepData();
                s.step_id         = col(row.rowData, 0);
                s.step_type       = col(row.rowData, 2);
                s.name            = col(row.rowData, 3);
                s.start_time      = col(row.rowData, 4);
                s.end_time        = col(row.rowData, 5);
                s.duration_ms     = durationMs(s.start_time, s.end_time);
                s.input           = notSet(col(row.rowData, 6));
                s.output          = notSet(col(row.rowData, 7));
                s.error           = notSet(col(row.rowData, 8));
                s.pre_vars        = notSet(col(row.rowData, 9));
                s.post_vars       = notSet(col(row.rowData, 10));
                s.generation_id   = notSet(col(row.rowData, 11));
                s.gateway_request_id = notSet(col(row.rowData, 12));

                if (s.error != null && 'ACTION_STEP'.equalsIgnoreCase(s.step_type)) convo.action_error_count++;
                t.steps.add(s);
            }
        }

        return JSON.serialize(convo);
    }

    /**
     * Retrieve full conversation details for multiple sessions.
     *
     * @param dataSpaceName  Data Cloud Data Space API name
     * @param sessionIds     List of ssot__Id__c values (keep under 20 to avoid CPU limits)
     * @return JSON-serialized List<ConversationData>
     */
    public static String getMultipleConversationDetails(String dataSpaceName, List<String> sessionIds) {
        List<ConversationData> results = new List<ConversationData>();
        if (sessionIds == null || sessionIds.isEmpty()) return JSON.serialize(results);

        for (String sid : sessionIds) {
            String detail = getConversationDetails(dataSpaceName, sid);
            if (String.isNotBlank(detail)) {
                ConversationData convo = (ConversationData) JSON.deserialize(detail, ConversationData.class);
                results.add(convo);
            }
        }
        return JSON.serialize(results);
    }

    /**
     * Retrieve LLM prompt and response for a set of LLM_STEP records by joining the
     * Einstein Audit & Feedback DMOs (GenAIGatewayRequest and GenAIGeneration).
     *
     * Typical use: after finding LLM_STEP records with non-null generation_id in a
     * ConversationData, pass those step IDs here to see what prompt was sent and what
     * the model actually returned — useful for diagnosing LOW instruction adherence.
     *
     * @param dataSpaceName  Data Cloud Data Space API name
     * @param stepIds        List of ssot__Id__c values from LLM_STEP StepData records
     * @return JSON-serialized List<LlmStepDetail>
     */
    public static String getLlmStepDetails(String dataSpaceName, List<String> stepIds) {
        if (stepIds == null || stepIds.isEmpty()) return JSON.serialize(new List<LlmStepDetail>());

        String inClause = '(\'' + String.join(stepIds, '\',\'') + '\')';
        String sql =
              'SELECT s.ssot__Id__c, s.ssot__AiAgentInteractionId__c, s.ssot__Name__c, '
            + '       r.prompt__c, g.responseText__c, '
            + '       s.ssot__GenerationId__c, s.ssot__GenAiGatewayRequestId__c '
            + 'FROM "ssot__AiAgentInteractionStep__dlm" s '
            + 'LEFT JOIN "GenAIGeneration__dlm" g '
            + '  ON s.ssot__GenerationId__c = g.generationId__c '
            + 'LEFT JOIN "GenAIGatewayRequest__dlm" r '
            + '  ON s.ssot__GenAiGatewayRequestId__c = r.gatewayRequestId__c '
            + 'WHERE s.ssot__Id__c IN ' + inClause;

        ConnectApi.CdpQueryOutputV2 result = runQuery(sql, dataSpaceName);
        List<LlmStepDetail> details = new List<LlmStepDetail>();

        if (result != null && result.data != null) {
            for (ConnectApi.CdpQueryV2Row row : result.data) {
                LlmStepDetail d      = new LlmStepDetail();
                d.step_id            = col(row.rowData, 0);
                d.interaction_id     = col(row.rowData, 1);
                d.step_name          = col(row.rowData, 2);
                d.prompt             = notSet(col(row.rowData, 3));
                d.llm_response       = notSet(col(row.rowData, 4));
                d.generation_id      = notSet(col(row.rowData, 5));
                d.gateway_request_id = notSet(col(row.rowData, 6));
                details.add(d);
            }
        }
        return JSON.serialize(details);
    }

    /**
     * Retrieve moment insights (intent summaries, durations) and retriever quality metrics
     * for a set of sessions. Gracefully degrades if DMOs are unavailable.
     *
     * @param dataSpaceName  Data Cloud Data Space API name
     * @param sessionIds     List of ssot__Id__c values from AiAgentSession
     * @return JSON-serialized List<SessionInsights>
     */
    public static String getMomentInsights(String dataSpaceName, List<String> sessionIds) {
        List<SessionInsights> results = new List<SessionInsights>();
        if (sessionIds == null || sessionIds.isEmpty()) return JSON.serialize(results);

        String inClause = '(\'' + String.join(sessionIds, '\',\'') + '\')';

        // --- Session headers ---
        String sessionSql =
              'SELECT ssot__Id__c, ssot__StartTimestamp__c, ssot__EndTimestamp__c, '
            + '       ssot__AiAgentSessionEndType__c '
            + 'FROM "ssot__AiAgentSession__dlm" '
            + 'WHERE ssot__Id__c IN ' + inClause;

        ConnectApi.CdpQueryOutputV2 sessionResult = runQuery(sessionSql, dataSpaceName);
        Map<String, SessionInsights> insightsById = new Map<String, SessionInsights>();

        if (sessionResult != null && sessionResult.data != null) {
            for (ConnectApi.CdpQueryV2Row row : sessionResult.data) {
                SessionInsights si = new SessionInsights();
                si.session_id   = col(row.rowData, 0);
                si.start_time   = col(row.rowData, 1);
                si.end_time     = col(row.rowData, 2);
                si.end_type     = notSet(col(row.rowData, 3));
                si.duration_ms  = durationMs(si.start_time, si.end_time);
                insightsById.put(si.session_id, si);
                results.add(si);
            }
        }

        // --- Turn counts ---
        String turnSql =
              'SELECT ssot__AiAgentSessionId__c, COUNT(*) '
            + 'FROM "ssot__AiAgentInteraction__dlm" '
            + 'WHERE ssot__AiAgentSessionId__c IN ' + inClause + ' '
            + '  AND ssot__AiAgentInteractionType__c = \'TURN\' '
            + 'GROUP BY ssot__AiAgentSessionId__c';

        ConnectApi.CdpQueryOutputV2 turnResult = runQuery(turnSql, dataSpaceName);
        if (turnResult != null && turnResult.data != null) {
            for (ConnectApi.CdpQueryV2Row row : turnResult.data) {
                SessionInsights si = insightsById.get(col(row.rowData, 0));
                if (si != null) {
                    si.turn_count = Integer.valueOf(col(row.rowData, 1));
                }
            }
        }

        // --- Action error counts ---
        String errSql =
              'SELECT ssot__AiAgentInteraction__dlm.ssot__AiAgentSessionId__c, COUNT(*) '
            + 'FROM "ssot__AiAgentInteractionStep__dlm" '
            + 'JOIN "ssot__AiAgentInteraction__dlm" '
            + '  ON ssot__AiAgentInteractionStep__dlm.ssot__AiAgentInteractionId__c = ssot__AiAgentInteraction__dlm.ssot__Id__c '
            + 'WHERE ssot__AiAgentInteraction__dlm.ssot__AiAgentSessionId__c IN ' + inClause + ' '
            + '  AND ssot__AiAgentInteractionStep__dlm.ssot__AiAgentInteractionStepType__c = \'ACTION_STEP\' '
            + '  AND ssot__AiAgentInteractionStep__dlm.ssot__ErrorMessageText__c IS NOT NULL '
            + '  AND ssot__AiAgentInteractionStep__dlm.ssot__ErrorMessageText__c != \'NOT_SET\' '
            + 'GROUP BY ssot__AiAgentInteraction__dlm.ssot__AiAgentSessionId__c';

        ConnectApi.CdpQueryOutputV2 errResult = runQuery(errSql, dataSpaceName);
        if (errResult != null && errResult.data != null) {
            for (ConnectApi.CdpQueryV2Row row : errResult.data) {
                SessionInsights si = insightsById.get(col(row.rowData, 0));
                if (si != null) {
                    si.action_error_count = Integer.valueOf(col(row.rowData, 1));
                }
            }
        }

        // --- Moments (graceful degradation) ---
        Boolean momentAvailable = isDmoAvailable('ssot__AiAgentMoment__dlm', dataSpaceName);
        if (momentAvailable) {
            String momentSql =
                  'SELECT ssot__Id__c, ssot__AiAgentSessionId__c, '
                + '       ssot__StartTimestamp__c, ssot__EndTimestamp__c, '
                + '       ssot__RequestSummaryText__c, ssot__ResponseSummaryText__c, '
                + '       ssot__AiAgentApiName__c, ssot__AiAgentVersionApiName__c '
                + 'FROM "ssot__AiAgentMoment__dlm" '
                + 'WHERE ssot__AiAgentSessionId__c IN ' + inClause + ' '
                + 'ORDER BY ssot__StartTimestamp__c';

            ConnectApi.CdpQueryOutputV2 momentResult = runQuery(momentSql, dataSpaceName);
            if (momentResult != null && momentResult.data != null) {
                for (ConnectApi.CdpQueryV2Row row : momentResult.data) {
                    MomentData m       = new MomentData();
                    m.moment_id        = col(row.rowData, 0);
                    m.session_id       = col(row.rowData, 1);
                    m.start_time       = col(row.rowData, 2);
                    m.end_time         = col(row.rowData, 3);
                    m.duration_ms      = durationMs(m.start_time, m.end_time);
                    m.request_summary  = notSet(col(row.rowData, 4));
                    m.response_summary = notSet(col(row.rowData, 5));
                    m.agent_api_name   = notSet(col(row.rowData, 6));
                    m.agent_version    = notSet(col(row.rowData, 7));

                    SessionInsights si = insightsById.get(m.session_id);
                    if (si != null) si.moments.add(m);
                }
            }
            // --- Quality scores via AiAgentTagAssociation → AiAgentTag ---
            if (isDmoAvailable('ssot__AiAgentTagAssociation__dlm', dataSpaceName)) {
                // Collect all moment IDs for the quality score query
                List<String> momentIds = new List<String>();
                Map<String, MomentData> momentById = new Map<String, MomentData>();
                for (SessionInsights si : results) {
                    for (MomentData m : si.moments) {
                        momentIds.add(m.moment_id);
                        momentById.put(m.moment_id, m);
                    }
                }

                if (!momentIds.isEmpty()) {
                    String momentInClause = '(\'' + String.join(momentIds, '\',\'') + '\')';
                    String qualitySql =
                          'SELECT ta.ssot__AiAgentMomentId__c, t.ssot__Value__c, '
                        + '       ta.ssot__AssociationReasonText__c '
                        + 'FROM "ssot__AiAgentTagAssociation__dlm" ta '
                        + 'JOIN "ssot__AiAgentTag__dlm" t '
                        + '  ON ta.ssot__AiAgentTagId__c = t.ssot__Id__c '
                        + 'WHERE ta.ssot__AiAgentMomentId__c IN ' + momentInClause;

                    ConnectApi.CdpQueryOutputV2 qualityResult = runQuery(qualitySql, dataSpaceName);
                    if (qualityResult != null && qualityResult.data != null) {
                        for (ConnectApi.CdpQueryV2Row row : qualityResult.data) {
                            String momentId = col(row.rowData, 0);
                            MomentData m = momentById.get(momentId);
                            if (m != null) {
                                m.quality_score     = toInteger(col(row.rowData, 1));
                                m.quality_reasoning = notSet(col(row.rowData, 2));
                            }
                        }
                    }
                }
            }
        } else {
            for (SessionInsights si : results) {
                si.debug_message = 'AiAgentMoment DMO not available in this org';
            }
        }

        // Set moment_count and avg_quality_score per session
        for (SessionInsights si : results) {
            si.moment_count = si.moments.size();
            Integer scoreSum = 0;
            Integer scoreCount = 0;
            for (MomentData m : si.moments) {
                if (m.quality_score != null) {
                    scoreSum += m.quality_score;
                    scoreCount++;
                }
            }
            if (scoreCount > 0) {
                si.avg_quality_score = Decimal.valueOf(scoreSum) / scoreCount;
            }
        }

        // --- Retriever quality metrics (graceful degradation) ---
        Boolean retrieverAvailable = isDmoAvailable('ssot__AiRetrieverQualityMetric__dlm', dataSpaceName);
        if (retrieverAvailable) {
            // Retriever metrics link to sessions via gateway request IDs on LLM steps.
            // Query all retriever metrics for gateway requests that belong to these sessions.
            String retrieverSql =
                  'SELECT r.ssot__Id__c, r.ssot__AiGatewayRequestId__c, '
                + '       r.ssot__AiRetrieverRequestId__c, r.ssot__RetrieverApiName__c, '
                + '       r.ssot__UserUtteranceText__c, '
                + '       r.ssot__FaithfulnessRelevancyScoreNumber__c, '
                + '       r.ssot__AnswerRelevancyScoreNumber__c, '
                + '       r.ssot__ContextPrecisionScoreNumber__c, '
                + '       i.ssot__AiAgentSessionId__c '
                + 'FROM "ssot__AiRetrieverQualityMetric__dlm" r '
                + 'JOIN "ssot__AiAgentInteractionStep__dlm" s '
                + '  ON r.ssot__AiGatewayRequestId__c = s.ssot__GenAiGatewayRequestId__c '
                + 'JOIN "ssot__AiAgentInteraction__dlm" i '
                + '  ON s.ssot__AiAgentInteractionId__c = i.ssot__Id__c '
                + 'WHERE i.ssot__AiAgentSessionId__c IN ' + inClause;

            ConnectApi.CdpQueryOutputV2 retResult = runQuery(retrieverSql, dataSpaceName);
            if (retResult != null && retResult.data != null) {
                for (ConnectApi.CdpQueryV2Row row : retResult.data) {
                    RetrieverMetricData rm  = new RetrieverMetricData();
                    rm.metric_id            = col(row.rowData, 0);
                    rm.gateway_request_id   = col(row.rowData, 1);
                    rm.retriever_request_id = col(row.rowData, 2);
                    rm.retriever_api_name   = notSet(col(row.rowData, 3));
                    rm.user_utterance       = notSet(col(row.rowData, 4));
                    rm.faithfulness         = toDecimal(col(row.rowData, 5));
                    rm.answer_relevance     = toDecimal(col(row.rowData, 6));
                    rm.context_precision    = toDecimal(col(row.rowData, 7));

                    String sessionId = col(row.rowData, 8);
                    SessionInsights si = insightsById.get(sessionId);
                    if (si != null) si.retriever_metrics.add(rm);
                }
            }
        }

        return JSON.serialize(results);
    }

    /**
     * Compute aggregated metrics across sessions in a date range.
     * Includes session rates (abandonment, escalation, deflection), top intents from moments,
     * and average RAG quality scores. Gracefully degrades when DMOs are unavailable.
     *
     * @param dataSpaceName  Data Cloud Data Space API name
     * @param startIso       ISO 8601 UTC start timestamp
     * @param endIso         ISO 8601 UTC end timestamp
     * @param maxRows        Maximum sessions to aggregate over
     * @param agentApiName   Agent MasterLabel to filter by (null = all agents)
     * @return JSON-serialized AggregatedMetrics
     */
    public static String getAggregatedMetrics(String dataSpaceName, String startIso, String endIso, Integer maxRows, String agentApiName) {
        AggregatedMetrics metrics = new AggregatedMetrics();
        metrics.end_type_counts  = new Map<String, Integer>();
        metrics.top_intents      = new Map<String, Integer>();
        metrics.unavailable_dmos = new List<String>();

        // Step 1: Find sessions (reuse existing method)
        String sessionsJson = findSessions(dataSpaceName, startIso, endIso, maxRows, agentApiName);
        List<SessionSummary> sessions = (List<SessionSummary>) JSON.deserialize(sessionsJson, List<SessionSummary>.class);
        metrics.total_sessions = sessions.size();

        if (sessions.isEmpty()) return JSON.serialize(metrics);

        // Compute session-level aggregates
        Decimal totalDurationSec = 0;
        Integer durationCount = 0;
        for (SessionSummary s : sessions) {
            // End type distribution
            String endType = s.end_type != null ? s.end_type : 'UNKNOWN';
            Integer cnt = metrics.end_type_counts.get(endType);
            metrics.end_type_counts.put(endType, cnt != null ? cnt + 1 : 1);

            // Duration
            if (s.duration_ms != null) {
                totalDurationSec += Decimal.valueOf(s.duration_ms) / 1000;
                durationCount++;
            }
        }

        if (durationCount > 0) {
            metrics.avg_session_duration_sec = totalDurationSec / durationCount;
        }

        // Session rates
        Integer userEnded = metrics.end_type_counts.get('USER_ENDED');
        Integer agentEnded = metrics.end_type_counts.get('AGENT_ENDED');
        Integer escalated = metrics.end_type_counts.get('ESCALATED');
        Integer total = metrics.total_sessions;

        metrics.abandonment_rate = Decimal.valueOf(userEnded != null ? userEnded : 0) / total;
        metrics.deflection_rate  = Decimal.valueOf(agentEnded != null ? agentEnded : 0) / total;
        metrics.escalation_rate  = Decimal.valueOf(escalated != null ? escalated : 0) / total;

        // Collect session IDs for sub-queries
        List<String> sessionIds = new List<String>();
        for (SessionSummary s : sessions) {
            sessionIds.add(s.session_id);
        }
        String inClause = '(\'' + String.join(sessionIds, '\',\'') + '\')';

        // Step 2: Total turns
        String turnSql =
              'SELECT COUNT(*) '
            + 'FROM "ssot__AiAgentInteraction__dlm" '
            + 'WHERE ssot__AiAgentSessionId__c IN ' + inClause + ' '
            + '  AND ssot__AiAgentInteractionType__c = \'TURN\'';

        ConnectApi.CdpQueryOutputV2 turnResult = runQuery(turnSql, dataSpaceName);
        if (turnResult != null && turnResult.data != null && !turnResult.data.isEmpty()) {
            metrics.total_turns = Integer.valueOf(col(turnResult.data[0].rowData, 0));
        }

        // Step 3: Moments — count + top intents
        if (isDmoAvailable('ssot__AiAgentMoment__dlm', dataSpaceName)) {
            // Total moment count
            String momentCountSql =
                  'SELECT COUNT(*) '
                + 'FROM "ssot__AiAgentMoment__dlm" '
                + 'WHERE ssot__AiAgentSessionId__c IN ' + inClause;

            ConnectApi.CdpQueryOutputV2 mcResult = runQuery(momentCountSql, dataSpaceName);
            if (mcResult != null && mcResult.data != null && !mcResult.data.isEmpty()) {
                metrics.total_moments = Integer.valueOf(col(mcResult.data[0].rowData, 0));
            }

            // Top intents by request summary (GROUP BY truncated summary)
            String intentSql =
                  'SELECT ssot__RequestSummaryText__c, COUNT(*) AS cnt '
                + 'FROM "ssot__AiAgentMoment__dlm" '
                + 'WHERE ssot__AiAgentSessionId__c IN ' + inClause + ' '
                + '  AND ssot__RequestSummaryText__c IS NOT NULL '
                + '  AND ssot__RequestSummaryText__c != \'NOT_SET\' '
                + 'GROUP BY ssot__RequestSummaryText__c '
                + 'ORDER BY cnt DESC '
                + 'LIMIT 20';

            ConnectApi.CdpQueryOutputV2 intentResult = runQuery(intentSql, dataSpaceName);
            if (intentResult != null && intentResult.data != null) {
                for (ConnectApi.CdpQueryV2Row row : intentResult.data) {
                    String intent = col(row.rowData, 0);
                    Integer intentCnt = Integer.valueOf(col(row.rowData, 1));
                    if (intent != null) {
                        // Truncate long summaries for the top_intents map key
                        if (intent.length() > 100) intent = intent.substring(0, 100) + '...';
                        metrics.top_intents.put(intent, intentCnt);
                    }
                }
            }
        } else {
            metrics.unavailable_dmos.add('ssot__AiAgentMoment__dlm');
        }

        // Step 3b: Quality scores via AiAgentTagAssociation → AiAgentTag
        metrics.quality_distribution = new Map<String, Integer>();
        if (isDmoAvailable('ssot__AiAgentTagAssociation__dlm', dataSpaceName)) {
            // AVG quality score and distribution across all moments in these sessions
            String qualityAvgSql =
                  'SELECT t.ssot__Value__c, COUNT(*) AS cnt '
                + 'FROM "ssot__AiAgentTagAssociation__dlm" ta '
                + 'JOIN "ssot__AiAgentTag__dlm" t '
                + '  ON ta.ssot__AiAgentTagId__c = t.ssot__Id__c '
                + 'WHERE ta.ssot__AiAgentSessionId__c IN ' + inClause + ' '
                + 'GROUP BY t.ssot__Value__c '
                + 'ORDER BY t.ssot__Value__c';

            ConnectApi.CdpQueryOutputV2 qualityResult = runQuery(qualityAvgSql, dataSpaceName);
            if (qualityResult != null && qualityResult.data != null) {
                Integer totalScore = 0;
                Integer totalCount = 0;
                for (ConnectApi.CdpQueryV2Row row : qualityResult.data) {
                    String scoreStr = col(row.rowData, 0);
                    Integer cnt = Integer.valueOf(col(row.rowData, 1));
                    if (scoreStr != null && cnt != null) {
                        Integer score = toInteger(scoreStr);
                        metrics.quality_distribution.put(String.valueOf(score), cnt);
                        if (score != null) {
                            totalScore += score * cnt;
                            totalCount += cnt;
                        }
                    }
                }
                if (totalCount > 0) {
                    metrics.avg_quality_score = Decimal.valueOf(totalScore) / totalCount;
                }
            }
        } else {
            metrics.unavailable_dmos.add('ssot__AiAgentTagAssociation__dlm');
        }

        // Step 4: Retriever quality averages
        if (isDmoAvailable('ssot__AiRetrieverQualityMetric__dlm', dataSpaceName)) {
            String retSql =
                  'SELECT AVG(r.ssot__FaithfulnessRelevancyScoreNumber__c), '
                + '       AVG(r.ssot__AnswerRelevancyScoreNumber__c), '
                + '       AVG(r.ssot__ContextPrecisionScoreNumber__c) '
                + 'FROM "ssot__AiRetrieverQualityMetric__dlm" r '
                + 'JOIN "ssot__AiAgentInteractionStep__dlm" s '
                + '  ON r.ssot__AiGatewayRequestId__c = s.ssot__GenAiGatewayRequestId__c '
                + 'JOIN "ssot__AiAgentInteraction__dlm" i '
                + '  ON s.ssot__AiAgentInteractionId__c = i.ssot__Id__c '
                + 'WHERE i.ssot__AiAgentSessionId__c IN ' + inClause;

            ConnectApi.CdpQueryOutputV2 retResult = runQuery(retSql, dataSpaceName);
            if (retResult != null && retResult.data != null && !retResult.data.isEmpty()) {
                metrics.avg_faithfulness      = toDecimal(col(retResult.data[0].rowData, 0));
                metrics.avg_answer_relevance  = toDecimal(col(retResult.data[0].rowData, 1));
                metrics.avg_context_precision = toDecimal(col(retResult.data[0].rowData, 2));
            }
        } else {
            metrics.unavailable_dmos.add('ssot__AiRetrieverQualityMetric__dlm');
        }

        return JSON.serialize(metrics);
    }

    // =========================================================================
    // Private helpers
    // =========================================================================

    /** Cache for DMO availability probes to avoid repeated queries. */
    private static Map<String, Boolean> dmoAvailabilityCache = new Map<String, Boolean>();

    /**
     * Probe whether a DMO exists and is queryable in this org's Data Cloud.
     * Results are cached for the transaction to avoid repeated probes.
     */
    private static Boolean isDmoAvailable(String dmoName, String dataSpaceName) {
        if (dmoAvailabilityCache.containsKey(dmoName)) return dmoAvailabilityCache.get(dmoName);
        ConnectApi.CdpQueryInput inp = new ConnectApi.CdpQueryInput();
        inp.sql = 'SELECT COUNT(*) FROM "' + dmoName + '"';
        try {
            ConnectApi.CdpQueryOutputV2 result = ConnectApi.CdpQuery.queryAnsiSqlV2(inp, dataSpaceName);
            dmoAvailabilityCache.put(dmoName, true);
            return true;
        } catch (Exception e) {
            System.debug(LoggingLevel.WARN, 'DMO not available: ' + dmoName + ' — ' + e.getMessage());
            dmoAvailabilityCache.put(dmoName, false);
            return false;
        }
    }

    /** Safely parse a string to Decimal; returns null on failure. */
    private static Decimal toDecimal(String val) {
        if (String.isBlank(val) || val == 'NOT_SET') return null;
        try {
            return Decimal.valueOf(val);
        } catch (Exception e) {
            return null;
        }
    }

    /** Safely parse a string to Integer (truncating decimals); returns null on failure. */
    private static Integer toInteger(String val) {
        if (String.isBlank(val) || val == 'NOT_SET') return null;
        try {
            return Decimal.valueOf(val).intValue();
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * Resolve an Agentforce agent name to its GenAiPlannerDefinition IDs.
     *
     * Each deployed agent version creates a GenAiPlannerDefinition whose MasterLabel
     * matches the agent's display name (e.g. 'TeslaSupportAgent'). Returning all
     * matching versions ensures historical sessions from older deployments are included.
     *
     * Also adds the 15-char version of each ID to handle STDM DMO inconsistency:
     * the participant DMO stores ssot__ParticipantId__c as either 15-char or 18-char.
     *
     * @param agentApiName  MasterLabel of the agent (same as the agent's display name)
     * @return List of GenAiPlannerDefinition Ids in both 15-char and 18-char formats; empty if not found
     */
    private static List<String> resolvePlannerIds(String agentApiName) {
        List<String> ids = new List<String>();
        try {
            // Search by MasterLabel (exact match) OR DeveloperName pattern.
            // Agent Script agents create GenAiPlannerDefinition with DeveloperName
            // like 'OrderService_v1' and MasterLabel like 'Order Service'.
            // Accept the API name (no spaces) and match both patterns.
            String devNamePattern = agentApiName + '_%';
            List<GenAiPlannerDefinition> planners = [
                SELECT Id FROM GenAiPlannerDefinition
                WHERE MasterLabel = :agentApiName
                   OR DeveloperName = :agentApiName
                   OR DeveloperName LIKE :devNamePattern
            ];
            for (GenAiPlannerDefinition p : planners) {
                String id18 = String.valueOf(p.Id);
                ids.add(id18);
                // STDM DMO stores IDs inconsistently (15-char or 18-char) — include both
                if (id18.length() == 18) ids.add(id18.substring(0, 15));
            }
            if (ids.isEmpty()) {
                System.debug(LoggingLevel.WARN, 'No GenAiPlannerDefinition found for: ' + agentApiName);
            } else {
                System.debug(LoggingLevel.DEBUG, 'Resolved ' + planners.size() + ' planner version(s) for agent "'
                    + agentApiName + '": ' + ids);
            }
        } catch (Exception e) {
            System.debug(LoggingLevel.WARN, 'resolvePlannerIds failed for "' + agentApiName + '": ' + e.getMessage());
        }
        return ids;
    }

    /** Extract ssot__AiAgentSessionId__c values (col 0) from a participant DMO query result. */
    private static List<String> extractSessionIds(ConnectApi.CdpQueryOutputV2 partResult) {
        List<String> sessionIds = new List<String>();
        if (partResult != null && partResult.data != null) {
            for (ConnectApi.CdpQueryV2Row row : partResult.data) {
                String sid = col(row.rowData, 0);
                if (sid != null) sessionIds.add(sid);
            }
        }
        return sessionIds;
    }

    private static ConnectApi.CdpQueryOutputV2 runQuery(String sql, String dataSpaceName) {
        System.debug(LoggingLevel.DEBUG, 'AgentforceOptimize CDP query (' + dataSpaceName + '): ' + sql);
        ConnectApi.CdpQueryInput inp = new ConnectApi.CdpQueryInput();
        inp.sql = sql;
        try {
            ConnectApi.CdpQueryOutputV2 result = ConnectApi.CdpQuery.queryAnsiSqlV2(inp, dataSpaceName);
            Integer rowCount = (result != null && result.data != null) ? result.data.size() : 0;
            System.debug(LoggingLevel.DEBUG, 'Rows returned: ' + rowCount);
            return result;
        } catch (Exception e) {
            System.debug(LoggingLevel.ERROR,
                'CDP query failed [' + dataSpaceName + ']: ' + e.getMessage() + ' | SQL: ' + sql);
            return null;
        }
    }

    /** Safely extract a column value as String; returns null for empty/null cells. */
    private static String col(List<Object> row, Integer i) {
        if (row == null || i >= row.size() || row[i] == null) return null;
        return String.valueOf(row[i]);
    }

    /** Return null when the value is the STDM "NOT_SET" sentinel or blank. */
    private static String notSet(String val) {
        if (String.isBlank(val) || val == 'NOT_SET') return null;
        return val;
    }

    /** Compute millisecond duration between two ISO timestamp strings; null on any failure. */
    private static Long durationMs(String startTs, String endTs) {
        if (String.isBlank(startTs) || String.isBlank(endTs)) return null;
        try {
            Datetime startDt = parseTs(startTs);
            Datetime endDt   = parseTs(endTs);
            if (startDt == null || endDt == null) return null;
            return endDt.getTime() - startDt.getTime();
        } catch (Exception e) {
            return null;
        }
    }

    /** Parse an ISO 8601 timestamp string into a Datetime, with fallback strategies. */
    private static Datetime parseTs(String ts) {
        if (String.isBlank(ts)) return null;
        // Strategy 1: ISO 8601 via JSON deserialise
        try {
            return (Datetime) JSON.deserialize('"' + ts + '"', Datetime.class);
        } catch (Exception e1) {
            System.debug(LoggingLevel.FINE, 'parseTs strategy 1 failed for "' + ts + '": ' + e1.getMessage());
        }
        // Strategy 2: Datetime.valueOf (handles 'yyyy-MM-dd HH:mm:ss' format)
        try {
            return Datetime.valueOf(ts);
        } catch (Exception e2) {
            System.debug(LoggingLevel.FINE, 'parseTs strategy 2 failed for "' + ts + '": ' + e2.getMessage());
        }
        // Strategy 3: epoch milliseconds
        try {
            return Datetime.newInstance(Long.valueOf(ts));
        } catch (Exception e3) {
            System.debug(LoggingLevel.FINE, 'parseTs strategy 3 failed for "' + ts + '": ' + e3.getMessage());
        }
        return null;
    }

    // =========================================================================
    // Observability Query (@InvocableMethod for Flow / Agentforce actions)
    // =========================================================================

    public class ObservabilityInput {
        @InvocableVariable(label='Query Type' required=true)
        public String queryType;

        @InvocableVariable(label='Agent API Name' required=false)
        public String agentApiName;

        @InvocableVariable(label='Topic API Name' required=false)
        public String topicApiName;

        @InvocableVariable(label='Lookback Days' required=false)
        public Integer lookbackDays;
    }

    public class ObservabilityOutput {
        @InvocableVariable(label='Query Result JSON')
        public String resultJson;

        @InvocableVariable(label='Summary Text')
        public String summaryText;
    }

    @InvocableMethod(label='Run Observability Query' description='Executes a Data Cloud observability query based on query type and optional filters.')
    public static List<ObservabilityOutput> runObservabilityQuery(List<ObservabilityInput> inputs) {
        List<ObservabilityOutput> results = new List<ObservabilityOutput>();
        for (ObservabilityInput input : inputs) {
            ObservabilityOutput out = new ObservabilityOutput();
            try {
                String query = buildObservabilityQuery(input);
                ConnectApi.CdpQueryInput cdpInput = new ConnectApi.CdpQueryInput();
                cdpInput.sql = query;
                ConnectApi.CdpQueryOutputV2 cdpOutput = ConnectApi.CdpQuery.queryAnsiSqlV2(cdpInput);

                out.resultJson = JSON.serialize(cdpOutput);
                Integer rowCount = cdpOutput.rowCount != null ? cdpOutput.rowCount : 0;

                if (rowCount == 0) {
                    out.summaryText = 'Query executed for ' + input.queryType + '. No results found for the given filters and time range. SQL: ' + query;
                } else {
                    out.summaryText = 'Query executed for ' + input.queryType + '. Found ' + rowCount + ' result(s). Use the metadata to map column positions to names in the data arrays.';
                }
            } catch (Exception e) {
                out.summaryText = 'Error executing ' + input.queryType + ' query: ' + e.getMessage();
                out.resultJson = '{"error":"' + e.getMessage().replace('"', '\\"') + '"}';
            }
            results.add(out);
        }
        return results;
    }

    private static String buildObservabilityQuery(ObservabilityInput p) {
        Integer days = p.lookbackDays != null ? p.lookbackDays : 90;
        String cutoff = Datetime.now().addDays(-days).formatGmt('yyyy-MM-dd HH:mm:ss.SSS');

        String agentFilter = String.isNotBlank(p.agentApiName)
            ? ' AND sp.aiAgentApiName__c = \'' + String.escapeSingleQuotes(p.agentApiName) + '\'' : '';
        String topicFilter = String.isNotBlank(p.topicApiName)
            ? ' AND i.topicApiName__c = \'' + String.escapeSingleQuotes(p.topicApiName) + '\'' : '';

        if (p.queryType == 'KnowledgeGap') {
            return 'SELECT i.topicApiName__c, sp.aiAgentApiName__c, ' +
                   'AVG(q.ContextPrecisionScoreNumber__c) AS avg_precision, ' +
                   'AVG(q.AnswerRelevancyScoreNumber__c) AS avg_relevancy, ' +
                   'COUNT(*) AS total_interactions ' +
                   'FROM AIRetrieverQualityMetric__dll q ' +
                   'JOIN AIAgentInteraction__dll i ON q.RetrieverTraceId__c = i.telemetryTraceId__c ' +
                   'JOIN AIAgentSessionParticipant__dll sp ON sp.aiAgentSessionId__c = i.aiAgentSessionId__c ' +
                   'WHERE i.startTimestamp__c > \'' + cutoff + '\'' +
                   agentFilter + topicFilter +
                   ' GROUP BY i.topicApiName__c, sp.aiAgentApiName__c ' +
                   'ORDER BY avg_precision ASC LIMIT 25';

        } else if (p.queryType == 'Hallucination') {
            return 'SELECT sp.aiAgentApiName__c, i.topicApiName__c, ' +
                   'AVG(q.FaithfulnessRelevancyScoreNumber__c) AS avg_faithfulness, ' +
                   'COUNT(*) AS total_interactions ' +
                   'FROM AIRetrieverQualityMetric__dll q ' +
                   'JOIN AIAgentInteraction__dll i ON q.RetrieverTraceId__c = i.telemetryTraceId__c ' +
                   'JOIN AIAgentSessionParticipant__dll sp ON sp.aiAgentSessionId__c = i.aiAgentSessionId__c ' +
                   'WHERE i.startTimestamp__c > \'' + cutoff + '\'' +
                   ' AND q.FaithfulnessRelevancyScoreNumber__c < 0.8' +
                   agentFilter + topicFilter +
                   ' GROUP BY sp.aiAgentApiName__c, i.topicApiName__c ' +
                   'ORDER BY avg_faithfulness ASC LIMIT 25';

        } else if (p.queryType == 'RetrievalQuality') {
            return 'SELECT q.RetrieverApiName__c, i.topicApiName__c, sp.aiAgentApiName__c, ' +
                   'AVG(q.ContextPrecisionScoreNumber__c) AS avg_precision, COUNT(*) AS total ' +
                   'FROM AIRetrieverQualityMetric__dll q ' +
                   'JOIN AIAgentInteraction__dll i ON q.RetrieverTraceId__c = i.telemetryTraceId__c ' +
                   'JOIN AIAgentSessionParticipant__dll sp ON sp.aiAgentSessionId__c = i.aiAgentSessionId__c ' +
                   'WHERE i.startTimestamp__c > \'' + cutoff + '\'' +
                   agentFilter + topicFilter +
                   ' GROUP BY q.RetrieverApiName__c, i.topicApiName__c, sp.aiAgentApiName__c ' +
                   'ORDER BY avg_precision ASC LIMIT 25';

        } else if (p.queryType == 'AnswerRelevancy') {
            return 'SELECT sp.aiAgentApiName__c, i.topicApiName__c, ' +
                   'AVG(q.AnswerRelevancyScoreNumber__c) AS avg_relevancy, COUNT(*) AS total ' +
                   'FROM AIRetrieverQualityMetric__dll q ' +
                   'JOIN AIAgentInteraction__dll i ON q.RetrieverTraceId__c = i.telemetryTraceId__c ' +
                   'JOIN AIAgentSessionParticipant__dll sp ON sp.aiAgentSessionId__c = i.aiAgentSessionId__c ' +
                   'WHERE i.startTimestamp__c > \'' + cutoff + '\'' +
                   ' AND q.AnswerRelevancyScoreNumber__c < 0.7' +
                   agentFilter + topicFilter +
                   ' GROUP BY sp.aiAgentApiName__c, i.topicApiName__c ' +
                   'ORDER BY avg_relevancy ASC LIMIT 25';

        } else if (p.queryType == 'Leaderboard') {
            return 'SELECT sp.aiAgentApiName__c, i.topicApiName__c, ' +
                   'AVG(q.ContextPrecisionScoreNumber__c) AS avg_precision, ' +
                   'AVG(q.AnswerRelevancyScoreNumber__c) AS avg_relevancy, ' +
                   'AVG(q.FaithfulnessRelevancyScoreNumber__c) AS avg_faithfulness, ' +
                   'COUNT(*) AS total_interactions ' +
                   'FROM AIRetrieverQualityMetric__dll q ' +
                   'JOIN AIAgentInteraction__dll i ON q.RetrieverTraceId__c = i.telemetryTraceId__c ' +
                   'JOIN AIAgentSessionParticipant__dll sp ON sp.aiAgentSessionId__c = i.aiAgentSessionId__c ' +
                   'WHERE i.startTimestamp__c > \'' + cutoff + '\'' +
                   agentFilter + topicFilter +
                   ' GROUP BY sp.aiAgentApiName__c, i.topicApiName__c ' +
                   'ORDER BY avg_precision ASC LIMIT 25';
        }
        return 'SELECT COUNT(*) AS cnt FROM AIAgentSession__dll';
    }
}
