/**
 * Escalation Router Service
 *
 * Routes escalations to the appropriate target:
 * - target === 'team_lead' → Send message to TL agent via MessageQueue
 * - target === 'human' → Send Slack notification to user + pause task + create pending record
 *
 * Also handles MissionPolicy escalation rules (cost_exceeded, failure_count, etc.)
 * by routing `escalateTo: 'user'` to human and `escalateTo: 'orchestrator'` to agent.
 *
 * Pending human escalations are stored in `.crewly/escalations/` and resolved
 * via POST /api/escalations/:id/resolve.
 *
 * @module services/v3/escalation-router.service
 */
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { LoggerService } from '../core/logger.service.js';
import { ensureDir, atomicWriteJson, safeReadJson } from '../../utils/file-io.utils.js';
import { getAgentBehaviorLogService } from '../observability/agent-behavior-log.singleton.js';
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
export class EscalationRouterService {
    static instance = null;
    logger;
    escalationsDir;
    constructor(projectPath) {
        this.logger = LoggerService.getInstance().createComponentLogger('EscalationRouter');
        this.escalationsDir = path.join(projectPath, '.crewly', 'escalations');
    }
    static getInstance(projectPath) {
        if (!EscalationRouterService.instance) {
            const p = projectPath || process.cwd();
            EscalationRouterService.instance = new EscalationRouterService(p);
        }
        return EscalationRouterService.instance;
    }
    static resetInstance() {
        EscalationRouterService.instance = null;
    }
    // ---------------------------------------------------------------------------
    // Route: AlignmentRequest from Worker
    // ---------------------------------------------------------------------------
    /**
     * Route a worker's AlignmentRequest to the correct target.
     *
     * @param request - The structured alignment request from the worker
     * @param workItemId - The WorkItem being escalated
     * @param workerSession - Worker's session name
     * @returns The escalation ID if routed to human, null if handled by agent
     */
    async routeAlignmentRequest(request, workItemId, workerSession) {
        // F14: record `agent.escalation` at the canonical worker→{TL,human}
        // entry point. Best-effort — never blocks the escalation flow. Why
        // this site: it is the single chokepoint every structured worker
        // alignment request flows through (target='team_lead' or 'human').
        // The `escalate` skill / send-message-with-action paths converge
        // here. Recording earlier (e.g. inside the skill bash) loses the
        // structured `target`/`reason` shape; recording later (inside
        // notifyHuman / notifyAgent) misses the handled-by-agent branch
        // when target='team_lead' returns null.
        try {
            getAgentBehaviorLogService()?.record({
                type: 'agent.escalation',
                escalatingAgent: workerSession,
                escalatedTo: request.target,
                reason: request.reason,
                taskId: workItemId,
                details: {
                    discoveredIssue: request.discoveredIssue,
                    decisionNeeded: request.decisionNeeded,
                },
            });
            // F14 ask_human boundary: when an agent escalates to a human
            // (Steve), this IS an `agent.action` with actionType='ask_human'.
            // We record both events so digests can compute (a) raw escalation
            // counts and (b) per-agent ask_human rates as a subset.
            if (request.target === 'human') {
                getAgentBehaviorLogService()?.record({
                    type: 'agent.action',
                    agent: workerSession,
                    actionType: 'ask_human',
                    taskId: workItemId,
                    details: {
                        reason: request.reason,
                        workItemId,
                    },
                });
            }
        }
        catch {
            /* observability is best-effort */
        }
        if (request.target === 'human') {
            // Create persistent record + notify human
            const escalation = await this.createPendingEscalation({
                source: 'alignment_request',
                target: 'human',
                summary: `Worker ${workerSession} escalated: ${request.discoveredIssue}`,
                details: {
                    currentTask: request.currentTask,
                    reason: request.reason,
                    whyCannotExecute: request.whyCannotExecute,
                    options: request.options,
                    recommendation: request.recommendation,
                    decisionNeeded: request.decisionNeeded,
                },
                workItemId,
                raisedBy: workerSession,
            });
            // Notify via Slack
            await this.notifyHuman(escalation);
            // Pause the work item
            await this.pauseWorkItem(workItemId);
            this.logger.info('Escalation routed to human', {
                escalationId: escalation.id,
                workItemId,
                reason: request.reason,
            });
            return escalation.id;
        }
        // target === 'team_lead' → route to TL agent
        await this.notifyAgent(request, workItemId, workerSession);
        this.logger.info('Escalation routed to team lead agent', {
            workItemId,
            reason: request.reason,
        });
        return null;
    }
    // ---------------------------------------------------------------------------
    // Route: MissionPolicy EscalationRule
    // ---------------------------------------------------------------------------
    /**
     * Route a MissionPolicy escalation rule trigger.
     *
     * @param mission - The Mission that triggered the rule
     * @param rule - The escalation rule that fired
     * @param context - Current context values (cost, time, failures)
     */
    async routePolicyEscalation(mission, rule, context) {
        if (rule.escalateTo === 'user') {
            const escalation = await this.createPendingEscalation({
                source: 'policy_rule',
                target: 'human',
                summary: `Mission "${mission.objective}" triggered ${rule.condition} (threshold: ${rule.threshold})`,
                details: {
                    condition: rule.condition,
                    threshold: rule.threshold,
                    currentValue: context[rule.condition] ?? 0,
                    action: rule.action,
                },
                missionId: mission.id,
                raisedBy: 'system',
            });
            await this.notifyHuman(escalation);
            // Execute the escalation action
            if (rule.action === 'pause' || rule.action === 'block') {
                try {
                    const { MissionExecutorService } = await import('./mission-executor.service.js');
                    await MissionExecutorService.getInstance().pauseMission(mission.id);
                }
                catch {
                    // Non-fatal
                }
            }
            return escalation.id;
        }
        // escalateTo === 'orchestrator' or 'team_lead' → route to agent
        this.logger.info('Policy escalation routed to agent', {
            missionId: mission.id,
            escalateTo: rule.escalateTo,
            condition: rule.condition,
        });
        return null;
    }
    // ---------------------------------------------------------------------------
    // Resolution
    // ---------------------------------------------------------------------------
    /**
     * Resolve a pending human escalation.
     *
     * @param escalationId - The escalation to resolve
     * @param resolution - What the human decided
     * @param resolvedBy - Who resolved it (user ID or name)
     * @returns The updated escalation, or null if not found
     */
    async resolve(escalationId, resolution, resolvedBy) {
        const escalation = await this.loadEscalation(escalationId);
        if (!escalation)
            return null;
        escalation.status = 'resolved';
        escalation.resolvedAt = new Date().toISOString();
        escalation.resolvedBy = resolvedBy;
        escalation.resolution = resolution;
        await this.saveEscalation(escalation);
        // Resume paused work item if applicable
        if (escalation.workItemId) {
            await this.resumeWorkItem(escalation.workItemId);
        }
        this.logger.info('Escalation resolved', {
            escalationId,
            resolvedBy,
            workItemId: escalation.workItemId,
        });
        return escalation;
    }
    /**
     * Escalate a WorkItem that exhausted its retry budget to the
     * orchestrator. The orchestrator receives a structured message via
     * the same MessageQueue used by `target='team_lead'` escalations,
     * decides whether to (a) surface to the user, (b) replan, (c) hand
     * off to a different agent. Also persists a `PendingEscalation`
     * record for the dashboard / API.
     *
     * Called from `V3DataService.onTaskFailed` once `retryCount >=
     * maxRetries`. Best-effort — exceptions are caught and logged so a
     * messaging blip doesn't strand the upstream failure handler.
     *
     * @param wi      - The failed WorkItem (post-`failItem`, with retryCount
     *                  and maxRetries reflecting the exhausted budget)
     * @param reason  - Final failure reason (same string written to wi.error)
     * @returns The persisted escalation id, or null on persistence failure
     */
    async escalateFailedWorkItem(wi, reason) {
        const escalationId = uuidv4();
        const escalation = {
            id: escalationId,
            status: 'pending',
            source: 'workitem_failed',
            target: 'orchestrator',
            summary: `WorkItem "${wi.title}" failed after ${wi.maxRetries} retries`,
            details: {
                workItemId: wi.id,
                title: wi.title,
                type: wi.type,
                target: wi.target ?? null,
                retryCount: wi.retryCount,
                maxRetries: wi.maxRetries,
                lastError: wi.error ?? reason,
                requestId: wi.requestId ?? null,
                missionId: wi.missionId ?? null,
                priority: wi.priority ?? null,
            },
            workItemId: wi.id,
            missionId: wi.missionId ?? undefined,
            taskId: wi.parentWorkItemId ?? undefined,
            raisedBy: wi.target ?? 'system',
            raisedAt: new Date().toISOString(),
        };
        try {
            await this.saveEscalation(escalation);
        }
        catch (err) {
            this.logger.warn('Failed to persist workitem_failed escalation (non-fatal)', {
                workItemId: wi.id,
                error: err instanceof Error ? err.message : String(err),
            });
            // Continue — even if persistence fails the orc message below
            // still has value (orc will surface to user).
        }
        // Build the orc-facing message. Designed to be self-contained so orc
        // can act without going back to the activity log to reconstruct
        // context. Sanitization is REQUIRED because `wi.title` flows from
        // user-derived Slack/Request text and `wi.error` from worker output
        // — both can contain newlines, ANSI escapes, or strings that would
        // collide with the [CHAT:] / [NOTIFY:] markers ORC parses in its
        // own outbound message protocol. We:
        //   - strip ANSI escape codes
        //   - flatten newlines so a multi-line error can't open a fake
        //     marker line
        //   - defuse the four ORC-recognized marker prefixes by inserting
        //     a zero-width space — visible to neither ORC nor humans, but
        //     prevents the marker from parsing
        //   - hard-cap each user-derived field so a giant stack trace
        //     can't blow past ORC's context budget
        const sanitize = (raw, maxLen) => String(raw)
            .replace(/\[[0-9;]*m/g, '') // strip ANSI color escapes
            .replace(/[\r\n]+/g, ' ') // flatten newlines
            .replace(/\[(CHAT|NOTIFY|EVENT|ESCALATION)/gi, '[​$1') // defuse markers
            .slice(0, maxLen);
        const safeTitle = sanitize(wi.title, 200);
        const safeError = sanitize(wi.error ?? reason, 2_000);
        const lines = [
            `[ESCALATION] WorkItem failed after ${wi.maxRetries} retries — your decision needed.`,
            '',
            `WorkItem: ${wi.id} "${safeTitle}"`,
            `Type:     ${wi.type}`,
            `Target:   ${wi.target ?? '(unassigned)'}`,
            `Attempts: ${wi.retryCount} / ${wi.maxRetries}`,
            '',
            `Last error: ${safeError}`,
            '',
            `Parent Request: ${wi.requestId ?? '(none)'}`,
            `Parent Mission: ${wi.missionId ?? '(none)'}`,
            '',
            'Suggested actions (per Escalation SOP in your prompt):',
            '  (a) Surface to the user with the failure reason + concrete options',
            '  (b) Replan: break the task differently and re-delegate',
            '  (c) Hand off to a different agent better suited to the task',
            '  (d) Cancel with a "blocked — needs spec clarification" note',
            '',
            `Escalation id: ${escalationId}`,
        ];
        try {
            const { MessageQueueService } = await import('../messaging/message-queue.service.js');
            const mq = new MessageQueueService(process.cwd());
            mq.enqueue({
                content: lines.join('\n'),
                conversationId: `escalation-${wi.id}-${Date.now()}`,
                source: 'system_event',
                sourceMetadata: {
                    type: 'escalation',
                    subtype: 'workitem_failed',
                    workItemId: wi.id,
                    escalationId,
                },
            });
            this.logger.info('Routed workitem_failed escalation to ORC via MessageQueue', {
                workItemId: wi.id,
                escalationId,
            });
        }
        catch (err) {
            this.logger.warn('Failed to enqueue workitem_failed message for ORC (non-fatal)', {
                workItemId: wi.id,
                error: err instanceof Error ? err.message : String(err),
            });
        }
        return escalationId;
    }
    /**
     * List all pending escalations.
     */
    async listPending() {
        try {
            await ensureDir(this.escalationsDir);
            const fs = await import('fs/promises');
            const files = await fs.readdir(this.escalationsDir);
            const escalations = [];
            for (const f of files) {
                if (!f.endsWith('.json'))
                    continue;
                const data = await safeReadJson(path.join(this.escalationsDir, f), null);
                if (data && data.status === 'pending') {
                    escalations.push(data);
                }
            }
            return escalations.sort((a, b) => new Date(b.raisedAt).getTime() - new Date(a.raisedAt).getTime());
        }
        catch {
            return [];
        }
    }
    // ---------------------------------------------------------------------------
    // Persistence
    // ---------------------------------------------------------------------------
    async createPendingEscalation(input) {
        const escalation = {
            id: uuidv4(),
            status: 'pending',
            ...input,
            raisedAt: new Date().toISOString(),
        };
        await this.saveEscalation(escalation);
        return escalation;
    }
    async saveEscalation(escalation) {
        await ensureDir(this.escalationsDir);
        await atomicWriteJson(path.join(this.escalationsDir, `${escalation.id}.json`), escalation);
    }
    async loadEscalation(id) {
        return safeReadJson(path.join(this.escalationsDir, `${id}.json`), null);
    }
    // ---------------------------------------------------------------------------
    // Notification
    // ---------------------------------------------------------------------------
    /**
     * Notify human via the same channel the original request came from.
     * Falls back to Slack → Chat UI if source channel can't be determined.
     */
    async notifyHuman(escalation) {
        const message = [
            escalation.summary,
            '',
            `Source: ${escalation.source}`,
            escalation.missionId ? `Mission: ${escalation.missionId}` : '',
            escalation.workItemId ? `WorkItem: ${escalation.workItemId}` : '',
            '',
            `Resolve via: POST /api/escalations/${escalation.id}/resolve`,
        ].filter(Boolean).join('\n');
        // Determine source channel from the escalation's associated WorkItem/Request
        const sourceChannel = await this.resolveSourceChannel(escalation);
        let notified = false;
        // Route to the original channel
        if (sourceChannel === 'slack' || !sourceChannel) {
            try {
                const { getSlackOrchestratorBridge } = await import('../slack/slack-orchestrator-bridge.js');
                const bridge = getSlackOrchestratorBridge();
                if (bridge) {
                    await bridge.sendNotification({
                        type: 'alert',
                        title: 'Human Decision Required',
                        message,
                        urgency: 'high',
                        timestamp: new Date().toISOString(),
                    });
                    notified = true;
                }
            }
            catch {
                // Slack not available
            }
        }
        // Google Chat and Telegram: use the messaging adapter pattern
        if (sourceChannel === 'google_chat' || sourceChannel === 'telegram') {
            try {
                // Route via the Slack bridge's sendNotification which handles multi-channel
                // For now, fall through to Slack as the bridge handles routing
                const { getSlackOrchestratorBridge } = await import('../slack/slack-orchestrator-bridge.js');
                const bridge = getSlackOrchestratorBridge();
                if (bridge) {
                    await bridge.sendNotification({
                        type: 'alert',
                        title: `Human Decision Required (via ${sourceChannel})`,
                        message,
                        urgency: 'high',
                        timestamp: new Date().toISOString(),
                    });
                    notified = true;
                }
            }
            catch {
                // Channel not available
            }
        }
        if (!notified) {
            this.logger.warn('Could not notify human via any channel', {
                escalationId: escalation.id,
                sourceChannel,
            });
        }
    }
    /**
     * Resolve the source channel for an escalation by tracing back to the
     * original Request/ChatMessage that started the work.
     */
    async resolveSourceChannel(escalation) {
        if (!escalation.workItemId)
            return null;
        try {
            const taskPool = (await import('../task-pool/task-pool.service.js')).TaskPoolService.getInstance();
            const allItems = await taskPool.getAllItems();
            const wi = allItems.find((w) => w.id === escalation.workItemId);
            if (!wi?.requestId)
                return null;
            // Load the Request to find its source conversation
            const { RequestService } = await import('./request.service.js');
            const request = await RequestService.getInstance().getById(wi.requestId);
            if (!request?.sourceConversationItemId)
                return null;
            // Infer channel from conversation ID pattern
            const { inferChannelTypeFromConversationId } = await import('../../types/chat.types.js');
            return inferChannelTypeFromConversationId(request.sourceConversationItemId) ?? null;
        }
        catch {
            return null; // Can't determine — will fallback to default
        }
    }
    /**
     * Notify TL agent via message queue (for team_lead-targeted escalations).
     */
    async notifyAgent(request, workItemId, workerSession) {
        try {
            const { MessageQueueService } = await import('../messaging/message-queue.service.js');
            const mq = new MessageQueueService(process.cwd());
            mq.enqueue({
                content: [
                    `[ESCALATION] Worker ${workerSession} needs alignment on task.`,
                    '',
                    `Issue: ${request.discoveredIssue}`,
                    `Reason: ${request.reason}`,
                    `Decision needed: ${request.decisionNeeded}`,
                    '',
                    `Options:`,
                    ...request.options.map((o, i) => `  ${i + 1}. ${o.description} (impact: ${o.impact})`),
                    '',
                    `Worker recommends: ${request.recommendation}`,
                ].join('\n'),
                conversationId: `escalation-${workItemId}-${Date.now()}`,
                source: 'system_event',
                sourceMetadata: { type: 'escalation', workItemId, workerSession },
            });
        }
        catch (err) {
            this.logger.debug('Agent notification failed (non-fatal)', {
                error: err instanceof Error ? err.message : String(err),
            });
        }
    }
    // ---------------------------------------------------------------------------
    // WorkItem pause/resume helpers
    // ---------------------------------------------------------------------------
    async pauseWorkItem(workItemId) {
        try {
            const taskPool = (await import('../task-pool/task-pool.service.js')).TaskPoolService.getInstance();
            await taskPool.updateItemStatus(workItemId, 'blocked');
        }
        catch {
            // Non-fatal
        }
    }
    async resumeWorkItem(workItemId) {
        try {
            const taskPool = (await import('../task-pool/task-pool.service.js')).TaskPoolService.getInstance();
            await taskPool.updateItemStatus(workItemId, 'queued');
        }
        catch {
            // Non-fatal
        }
    }
}
//# sourceMappingURL=escalation-router.service.js.map