/**
 * VoiceSessionManager
 *
 * Manages long-lived Claude Agent SDK run loops for voice calls.
 *
 * Architecture overview
 * ---------------------
 * Text chat uses a stateless per-turn model: each user message becomes an
 * independent POST /tasks request that starts a new SDK process.  Voice calls
 * need a different model because:
 *   1. Calls can last minutes; spawning a new process per utterance is too slow.
 *   2. The SDK supports native session resume (sdkSessionId), letting it carry
 *      tool state and conversation context across turns without re-sending history.
 *   3. The agent must be explicitly shut down when the user hangs up, not just
 *      left running until it times out.
 *
 * This service implements the alternative model:
 *   • One background run loop per active voice call (Node.js async, not a thread).
 *   • An AsyncQueue<string | null> bridges HTTP inject requests to the loop.
 *   • The loop blocks on queue.dequeue() between turns (zero CPU while idle).
 *   • On each turn it calls ClaudeAgentSdkEngine.executeTask() and streams the
 *     resulting events to the WebSocket gateway (picked up by voice-agent → TTS).
 *   • The SDK session ID returned after each turn is saved to AgentSession.metadata
 *     so the NEXT turn's executeTask() call can resume from where the previous
 *     turn left off (native SDK resume, no re-sending of conversation history).
 *
 * Lifecycle
 * ---------
 *   startSession(sessionId)      — create queue + AbortController, start loop
 *   injectMessage(sessionId)     — enqueue speech turn; if a turn is running,
 *                                  abort it first (per-turn interrupt support)
 *   terminateSession(sessionId)  — abort session + drain queue + enqueue null
 *                                  sentinel; wait ≤5 s for loop to exit
 *
 * Interruption model (per-turn AbortController)
 * ---------------------------------------------
 * Two levels of abort exist:
 *
 *   sessionAbortController  — session-scoped; fired only on terminateSession().
 *                             Propagates into each turn via an event listener.
 *
 *   turnAbort (per turn)    — created fresh for each executeTurn() call.
 *                             Stored as handle.currentTurnAbort so that
 *                             injectMessage() can abort the RUNNING turn before
 *                             enqueuing the new message.
 *
 * When the user interrupts (speaks while the agent is responding):
 *   1. voice-agent LiveKit framework stops TTS playback immediately.
 *   2. voice-agent calls POST /:sessionId/voice/inject with new utterance.
 *   3. injectMessage() sees handle.currentTurnAbort !== null → aborts it.
 *   4. The for-await loop in executeTurn() checks turnAbort.signal.aborted
 *      on the NEXT received SDK event → breaks silently (no error emitted).
 *   5. The new message is enqueued; the loop dequeues it and starts a fresh turn.
 *
 * Because `break` does not throw, the catch block's error-event emission is
 * never triggered by an interrupt — the user hears no "agent error" message.
 * Any partial assistant text accumulated before the break is saved to
 * conversation history with a "[中断]" suffix for context continuity.
 *
 * Tenant context
 * --------------
 * The run loop is a background Promise, outside any HTTP request context.
 * TenantContextService.run() wraps each executeTurn() call to inject the
 * tenant's AsyncLocalStorage context (schema name, quotas, etc.) — the same
 * pattern used by the standing-order executor in ops-service.
 */
import { Injectable, Logger } from '@nestjs/common';
import { AsyncQueue } from '../../infrastructure/voice/async-queue';
import { EngineRegistry } from '../../infrastructure/engines/engine-registry';
import { ClaudeAgentSdkEngine } from '../../infrastructure/engines/claude-agent-sdk/claude-agent-sdk-engine';
import { AgentStreamGateway } from '../../interfaces/ws/agent-stream.gateway';
import { SessionRepository } from '../../infrastructure/repositories/session.repository';
import { TaskRepository } from '../../infrastructure/repositories/task.repository';
import { ConversationContextService } from './conversation-context.service';
import { AgentEngineType } from '../value-objects/agent-engine-type.vo';
import { TaskStatus } from '../value-objects/task-status.vo';
import { AgentTask } from '../entities/agent-task.entity';
import { TenantContextService } from '@it0/common';
import * as crypto from 'crypto';

/** Sentinel value enqueued to signal the run loop to exit. */
const TERMINATE: null = null;

/** In-memory handle for a running voice session. */
interface VoiceSessionHandle {
  /** Message queue: string = user speech turn; null = terminate signal. */
  queue: AsyncQueue<string | null>;
  /** Aborts the entire run loop (used on session terminate). */
  abortController: AbortController;
  /** Aborts the currently-executing SDK turn only (replaced each turn). */
  currentTurnAbort: AbortController | null;
  /** Tenant who owns this voice session. */
  tenantId: string;
  /** Background run-loop promise (resolved when loop exits). */
  runLoop: Promise<void>;
}

@Injectable()
export class VoiceSessionManager {
  private readonly logger = new Logger(VoiceSessionManager.name);

  /** Map from agentSessionId → handle. */
  private readonly sessions = new Map<string, VoiceSessionHandle>();

  constructor(
    private readonly engineRegistry: EngineRegistry,
    private readonly gateway: AgentStreamGateway,
    private readonly sessionRepository: SessionRepository,
    private readonly taskRepository: TaskRepository,
    private readonly contextService: ConversationContextService,
  ) {}

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  /**
   * Start a long-lived voice agent session.
   * Safe to call multiple times for the same sessionId (idempotent).
   */
  async startSession(sessionId: string, tenantId: string): Promise<void> {
    if (this.sessions.has(sessionId)) {
      this.logger.warn(`[VoiceSession ${sessionId}] Already active — ignoring duplicate start`);
      return;
    }

    const queue = new AsyncQueue<string | null>();
    const abortController = new AbortController();

    const handle: VoiceSessionHandle = {
      queue,
      abortController,
      currentTurnAbort: null,
      tenantId,
      runLoop: this.runLoop(sessionId, tenantId, queue, abortController),
    };

    this.sessions.set(sessionId, handle);
    this.logger.log(`[VoiceSession ${sessionId}] Started for tenant=${tenantId}`);
  }

  /**
   * Inject a new speech-turn message into the running voice session.
   * Returns true if injected, false if session not found.
   */
  async injectMessage(sessionId: string, message: string): Promise<boolean> {
    const handle = this.sessions.get(sessionId);
    if (!handle) return false;

    // If a turn is currently running, abort it immediately so the new message
    // can be processed without waiting for the old SDK call to finish.
    if (handle.currentTurnAbort) {
      this.logger.log(`[VoiceSession ${sessionId}] Interrupting current turn for new message`);
      handle.currentTurnAbort.abort();
      handle.currentTurnAbort = null;
    }

    handle.queue.enqueue(message);
    this.logger.log(`[VoiceSession ${sessionId}] Injected: "${message.slice(0, 80)}"`);
    return true;
  }

  /**
   * Terminate the voice session (user hung up or explicitly ended).
   * Enqueues the poison-pill, aborts any in-flight SDK call,
   * and waits for the run loop to exit.
   */
  async terminateSession(sessionId: string): Promise<void> {
    const handle = this.sessions.get(sessionId);
    if (!handle) {
      this.logger.debug(`[VoiceSession ${sessionId}] Already gone — ignoring terminate`);
      return;
    }

    this.logger.log(`[VoiceSession ${sessionId}] Terminating…`);

    // Abort any in-flight executeTask call
    handle.abortController.abort();

    // Poison-pill: unblock the queue.dequeue() if loop is idle between turns
    handle.queue.drain(TERMINATE);
    handle.queue.enqueue(TERMINATE);

    // Wait for loop to exit (max 5 s to avoid hanging the HTTP request)
    await Promise.race([
      handle.runLoop.catch(() => {}),
      new Promise<void>((r) => setTimeout(r, 5_000)),
    ]);

    this.sessions.delete(sessionId);
    this.logger.log(`[VoiceSession ${sessionId}] Terminated`);
  }

  /** Returns true if a run loop is active for this session. */
  isActive(sessionId: string): boolean {
    return this.sessions.has(sessionId);
  }

  // ---------------------------------------------------------------------------
  // Private: run loop
  // ---------------------------------------------------------------------------

  /**
   * Background run loop — one instance per voice session.
   * Blocks on queue.dequeue() between speech turns,
   * then executes one SDK turn and streams events to the gateway.
   */
  private async runLoop(
    sessionId: string,
    tenantId: string,
    queue: AsyncQueue<string | null>,
    abortController: AbortController,
  ): Promise<void> {
    // Always get the SDK engine for voice
    const engine = this.engineRegistry.switchEngine(AgentEngineType.CLAUDE_AGENT_SDK) as ClaudeAgentSdkEngine;

    try {
      while (!abortController.signal.aborted) {
        // Block until next speech turn (or terminate signal)
        const message = await queue.dequeue();
        if (message === null) break;

        // Execute one turn within the tenant's AsyncLocalStorage context
        await TenantContextService.run(
          {
            tenantId,
            tenantName: tenantId,
            plan: 'enterprise',
            schemaName: `it0_t_${tenantId}`,
            maxServers: -1,
            maxUsers: -1,
            maxStandingOrders: -1,
            maxAgentTokensPerMonth: -1,
          },
          async () => {
            await this.executeTurn(engine, sessionId, tenantId, message, abortController);
          },
        );
      }
    } catch (err: any) {
      if (err?.name !== 'AbortError') {
        this.logger.error(`[VoiceSession ${sessionId}] Run loop error: ${err?.message}`);
      }
    } finally {
      this.sessions.delete(sessionId);
      this.logger.log(`[VoiceSession ${sessionId}] Run loop exited`);
    }
  }

  /**
   * Execute a single speech turn:
   *   1. Create a task record
   *   2. Save user message to conversation history
   *   3. Load context + SDK resume session ID
   *   4. Run engine.executeTask() and stream events to gateway
   *   5. Save assistant response + capture new SDK session ID for next resume
   */
  private async executeTurn(
    engine: ClaudeAgentSdkEngine,
    sessionId: string,
    tenantId: string,
    message: string,
    sessionAbortController: AbortController,
  ): Promise<void> {
    // Create a per-turn abort controller so this turn can be interrupted
    // independently when the user speaks again mid-response.
    const turnAbort = new AbortController();
    const handle = this.sessions.get(sessionId);
    if (handle) handle.currentTurnAbort = turnAbort;

    // Combine session-level abort with turn-level abort: if either fires, abort the turn.
    const onSessionAbort = () => turnAbort.abort();
    sessionAbortController.signal.addEventListener('abort', onSessionAbort, { once: true });
    const session = await this.sessionRepository.findById(sessionId);
    if (!session) {
      this.logger.error(`[VoiceSession ${sessionId}] Session not found in DB — cannot execute turn`);
      return;
    }

    // Create task record for tracking
    const task = new AgentTask();
    task.id = crypto.randomUUID();
    task.tenantId = tenantId;
    task.sessionId = sessionId;
    task.prompt = message;
    task.status = TaskStatus.RUNNING;
    task.startedAt = new Date();
    task.createdAt = new Date();
    await this.taskRepository.save(task);

    // Notify WS subscribers about the new task so they can track it
    this.gateway.emitStreamEvent(sessionId, { type: 'task_info', taskId: task.id });

    // Save user message to conversation history
    await this.contextService.saveUserMessage(sessionId, message);

    // Load context (max 20 messages) — strip the latest user message since
    // the engine adds it as the explicit prompt parameter
    const history = await this.contextService.loadContext(sessionId, 20);
    const historyForEngine = history.slice(0, -1);

    // SDK resume: continue from previous turn if available
    const resumeSessionId = (session.metadata as any)?.sdkSessionId as string | undefined;
    if (resumeSessionId) {
      this.logger.log(`[VoiceSession ${sessionId}] Resuming SDK session: ${resumeSessionId}`);
    }

    // Voice-mode system prompt: concise oral Chinese, no markdown / tool details
    const voiceSystemPrompt =
      '你正在通过语音与用户实时对话。请严格遵守以下规则：\n' +
      '1. 只输出用户关注的最终答案，不要输出工具调用过程、中间步骤或技术细节\n' +
      '2. 用简洁自然的口语中文回答，像面对面对话一样\n' +
      '3. 回复要简短精炼，适合语音播报，通常1-3句话即可\n' +
      '4. 不要使用markdown格式、代码块、列表符号等文本格式';

    // Events to suppress in voice mode (only text/completed/error reach TTS)
    const voiceFilteredTypes = new Set(['thinking', 'tool_use', 'tool_result']);

    const textParts: string[] = [];
    let finished = false;

    try {
      const stream = engine.executeTask({
        sessionId,
        prompt: message,
        systemPrompt: voiceSystemPrompt,
        allowedTools: [],
        maxTurns: 10,
        conversationHistory: historyForEngine.length > 0 ? historyForEngine : undefined,
        resumeSessionId,
      });

      for await (const event of stream) {
        // Exit early if this turn was interrupted (user spoke again) or session terminated
        if (turnAbort.signal.aborted) break;

        if (!voiceFilteredTypes.has(event.type)) {
          this.gateway.emitStreamEvent(sessionId, event);
        }

        if (event.type === 'text') {
          textParts.push(event.content);
        }

        if (event.type === 'completed' && !finished) {
          finished = true;
          task.status = TaskStatus.COMPLETED;
          task.result = event.summary;
          task.tokensUsed = event.tokensUsed;
          task.completedAt = new Date();
          await this.taskRepository.save(task);

          // Persist assistant response to conversation history
          const assistantText = textParts.join('') || event.summary;
          if (assistantText) {
            await this.contextService.saveAssistantMessage(sessionId, assistantText);
          }

          // Capture the new SDK session ID so the next turn can resume from here
          const sdkSessionId = engine.getSdkSessionId(sessionId);
          session.metadata = {
            ...session.metadata as Record<string, unknown>,
            sdkSessionId: sdkSessionId ?? (session.metadata as any)?.sdkSessionId,
          };
          session.status = 'active';
          session.updatedAt = new Date();
          await this.sessionRepository.save(session);
        }

        if (event.type === 'error' && !finished) {
          finished = true;
          task.status = TaskStatus.FAILED;
          task.result = event.message;
          task.completedAt = new Date();
          await this.taskRepository.save(task);
        }
      }
    } catch (err: any) {
      if (!finished && err?.name !== 'AbortError') {
        task.status = TaskStatus.FAILED;
        task.result = err?.message ?? 'Voice session turn error';
        task.completedAt = new Date();
        await this.taskRepository.save(task);

        this.gateway.emitStreamEvent(sessionId, {
          type: 'error',
          message: task.result ?? 'Voice session turn error',
          code: 'VOICE_TURN_ERROR',
        });
      }
    } finally {
      // Remove the session-abort listener to avoid memory leaks
      sessionAbortController.signal.removeEventListener('abort', onSessionAbort);

      // Clear the per-turn abort ref on the handle (if it still points to this turn)
      if (handle && handle.currentTurnAbort === turnAbort) {
        handle.currentTurnAbort = null;
      }

      // If aborted mid-turn, save any partial text accumulated before the abort
      if (!finished && textParts.length > 0) {
        await this.contextService
          .saveAssistantMessage(sessionId, textParts.join('') + '\n[中断]')
          .catch(() => {});
      }
    }
  }
}
