import { EventEmitter } from 'node:events';
import { buildSessionConfig, buildSystemPrompt } from './config-builder';
import type { EventStream } from './event-stream';
import { logger } from './logger';
import type { OpenCodeClient } from './opencode-client';
import type {
  MessageInfo,
  PartUpdate,
  PermissionRequest,
  QuestionRequest,
  ResumeSessionPayload,
  Session,
  StartTaskPayload,
  Todo,
} from './types';

/**
 * Maximum consecutive auto-compactions before the sidecar treats the session
 * as stuck in a compaction loop and auto-aborts.
 *
 * OpenCode's auto-compaction injects a synthetic "Continue if you have next
 * steps…" user message after each compaction.  When the system prompt is
 * large enough to nearly fill the context window, the agent's short reply
 * immediately triggers another compaction, creating an infinite loop.
 */
const COMPACTION_LOOP_THRESHOLD = 3;

interface ManagedSession {
  taskId: string;
  sessionId: string;
  session: Session;
  status: 'starting' | 'active' | 'completing' | 'completed' | 'error';
  currentMessageId?: string;
  textAccumulator: string;
  consecutiveCompactions: number;
}

/**
 * Parse a composite model ID (e.g. "openrouter/minimax/minimax-m2.5") into
 * the { providerID, modelID } shape that OpenCode's sendMessage API expects.
 * This bypasses config-based model resolution, which fails for models not
 * in OpenCode's models.dev curated database.
 */
function parseModelId(modelId?: string): { providerID: string; modelID: string } | undefined {
  if (!modelId) return undefined;
  const slashIdx = modelId.indexOf('/');
  if (slashIdx <= 0) return undefined;
  return {
    providerID: modelId.substring(0, slashIdx),
    modelID: modelId.substring(slashIdx + 1),
  };
}

export class SessionManager extends EventEmitter {
  private client: OpenCodeClient;
  private eventStream: EventStream;
  private sessions: Map<string, ManagedSession> = new Map();
  private sessionToTask: Map<string, string> = new Map();
  private serverPort: number;
  private serverPassword: string;

  constructor(client: OpenCodeClient, eventStream: EventStream, serverPort: number, serverPassword: string) {
    super();
    this.client = client;
    this.eventStream = eventStream;
    this.serverPort = serverPort;
    this.serverPassword = serverPassword;
    this.setupEventListeners();
  }

  private setupEventListeners(): void {
    // Session status updates
    // Server sends { sessionID: string, status: { type: string } } not { session: Session }
    this.eventStream.on('session.status', (props: { sessionID: string; status: { type: string } }) => {
      const taskId = this.sessionToTask.get(props.sessionID);
      if (!taskId) return;

      const managed = this.sessions.get(taskId);
      if (!managed) return;

      logger.debug('Session status update', { sessionId: props.sessionID, status: props.status });

      if (props.status.type === 'idle') {
        this.handleSessionIdle(managed);
      } else if (props.status.type === 'busy') {
        managed.status = 'active';
        this.emit('progress', {
          taskId,
          stage: 'executing',
        });
      }
    });

    // Message updates
    // Server sends { info: MessageInfo } — sessionID is nested inside info
    this.eventStream.on('message.updated', (props: { info: MessageInfo }) => {
      const taskId = this.sessionToTask.get(props.info.sessionID);
      if (!taskId) return;

      const managed = this.sessions.get(taskId);
      if (!managed) return;

      if (props.info.role === 'assistant') {
        // Finalize previous message's accumulated text before starting new one
        if (managed.textAccumulator && managed.currentMessageId) {
          this.emit('message-complete', {
            taskId,
            messageId: managed.currentMessageId,
            text: managed.textAccumulator,
          });
          managed.textAccumulator = '';
        }
        managed.currentMessageId = props.info.id;
        this.emit('message', {
          taskId,
          message: props.info,
        });
      }
    });

    // Message part deltas (streaming text chunks)
    // Server sends incremental text via message.part.delta events
    this.eventStream.on(
      'message.part.delta',
      (props: { sessionID: string; messageID: string; partID: string; field: string; delta: string }) => {
        const taskId = this.sessionToTask.get(props.sessionID);
        if (!taskId) return;

        const managed = this.sessions.get(taskId);
        if (!managed) return;

        if (props.field === 'text') {
          managed.textAccumulator += props.delta;
          this.emit('message-partial', {
            taskId,
            messageId: props.messageID,
            textSoFar: managed.textAccumulator,
            delta: props.delta,
            isStreaming: true,
          });
        }
      }
    );

    // Message part updates (full part state, e.g. tool use, step-start/finish)
    // Server nests sessionID and messageID inside the part object itself
    this.eventStream.on('message.part.updated', (props: { part: PartUpdate; delta?: string }) => {
      const taskId = this.sessionToTask.get(props.part.sessionID);
      if (!taskId) return;

      const managed = this.sessions.get(taskId);
      if (!managed) return;

      if (props.part.type === 'text' && props.delta) {
        managed.textAccumulator += props.delta;
        this.emit('message-partial', {
          taskId,
          messageId: props.part.messageID,
          textSoFar: managed.textAccumulator,
          delta: props.delta,
          isStreaming: true,
        });
      } else if (props.part.type === 'tool') {
        this.emit('tool-use', {
          taskId,
          messageId: props.part.messageID,
          part: props.part,
        });
      }
    });

    // Permission requests
    this.eventStream.on('permission.asked', (props: PermissionRequest) => {
      const taskId = this.sessionToTask.get(props.sessionID);
      if (!taskId) return;

      logger.info('Permission request received', props);
      this.emit('permission-request', {
        taskId,
        id: props.id,
        sessionId: props.sessionID,
        permission: props.permission,
        patterns: props.patterns,
        metadata: props.metadata,
      });
    });

    // Question requests
    this.eventStream.on('question.asked', (props: QuestionRequest) => {
      const taskId = this.sessionToTask.get(props.sessionID);
      if (!taskId) return;

      logger.info('Question request received', props);
      this.emit('question-request', {
        taskId,
        id: props.id,
        sessionId: props.sessionID,
        questions: props.questions.map((q) => ({
          ...q,
          multiSelect: q.multiSelect ?? q.multiple ?? false,
        })),
      });
    });

    // Session errors
    this.eventStream.on('session.error', (props: { sessionID: string; error: string }) => {
      const taskId = this.sessionToTask.get(props.sessionID);
      if (!taskId) return;

      const managed = this.sessions.get(taskId);
      if (!managed) return;

      logger.error('Session error', props);
      managed.status = 'error';
      this.emit('error', {
        taskId,
        error: props.error,
        sessionId: props.sessionID,
      });
      this.cleanup(taskId);
    });

    // Compaction loop detection
    this.eventStream.on('session.compacted', (props: { sessionID: string }) => {
      const taskId = this.sessionToTask.get(props.sessionID);
      if (!taskId) return;

      const managed = this.sessions.get(taskId);
      if (!managed) return;

      managed.consecutiveCompactions += 1;
      logger.warn('Session compacted', {
        sessionId: props.sessionID,
        consecutiveCompactions: managed.consecutiveCompactions,
      });

      if (managed.consecutiveCompactions >= COMPACTION_LOOP_THRESHOLD) {
        logger.error('Compaction loop detected — auto-aborting session', {
          taskId,
          sessionId: props.sessionID,
          consecutiveCompactions: managed.consecutiveCompactions,
        });

        managed.status = 'error';
        this.emit('error', {
          taskId,
          error:
            "The model entered a compaction loop (the system prompt may be too large for this model's context window). " +
            'Try a model with a larger context window, or reduce the system prompt size.',
          sessionId: props.sessionID,
        });

        this.client.abortSession(props.sessionID, managed.session?.directory).catch((err) => {
          logger.warn('Failed to abort compaction-looping session', {
            taskId,
            error: err instanceof Error ? err.message : String(err),
          });
        });

        this.cleanup(taskId);
      }
    });

    // Todo updates
    this.eventStream.on('todo.updated', (props: { sessionID: string; todos: Todo[] }) => {
      const taskId = this.sessionToTask.get(props.sessionID);
      if (!taskId) return;

      logger.debug('Todo update received', { sessionId: props.sessionID, todoCount: props.todos.length });
      this.emit('todo-updated', {
        taskId,
        todos: props.todos,
      });
    });
  }

  private handleSessionIdle(managed: ManagedSession): void {
    if (managed.status === 'active') {
      // Finalize any accumulated text
      if (managed.textAccumulator && managed.currentMessageId) {
        this.emit('message-complete', {
          taskId: managed.taskId,
          messageId: managed.currentMessageId,
          text: managed.textAccumulator,
        });
      }

      managed.status = 'completed';
      managed.textAccumulator = '';
      managed.currentMessageId = undefined;

      this.emit('complete', {
        taskId: managed.taskId,
        sessionId: managed.sessionId,
        status: 'success',
      });

      this.cleanup(managed.taskId);
    }
  }

  async startTask(payload: StartTaskPayload): Promise<void> {
    const { taskId, prompt, workingDirectory, modelId, folderPermissions, customPrompt, mcpServers, skipConfig, arenaId } = payload;

    logger.info('Starting task', { taskId, prompt: prompt.slice(0, 100), arenaId });

    // Clean up stale sessions — but skip cleanup for arena tasks (all 3 must coexist)
    if (!arenaId) {
      const staleTaskIds = Array.from(this.sessions.keys()).filter((id) => id !== taskId);
      for (const oldTaskId of staleTaskIds) {
        const managed = this.sessions.get(oldTaskId);
        logger.info('Cleaning up stale session', { oldTaskId, sessionId: managed?.sessionId, status: managed?.status });
        this.cleanup(oldTaskId);
      }
    }

    // Push session-specific config via PATCH /config (skip if arena already sent it)
    if (skipConfig) {
      logger.info('Skipping config update (arena skipConfig)', { taskId });
    } else {
      const config = buildSessionConfig({ modelId, folderPermissions, mcpServers });
      await this.client.updateConfig(config, workingDirectory);
      logger.info('Config updated for session', config);
    }

    this.emit('progress', { taskId, stage: 'configuring' });

    // Create new session
    const session = await this.client.createSession({
      directory: workingDirectory,
      title: prompt.slice(0, 50),
    });

    const managed: ManagedSession = {
      taskId,
      sessionId: session.id,
      session,
      status: 'starting',
      textAccumulator: '',
      consecutiveCompactions: 0,
    };

    this.sessions.set(taskId, managed);
    this.sessionToTask.set(session.id, taskId);

    logger.info('Session created', { taskId, sessionId: session.id });

    this.emit('started', { taskId, sessionId: session.id });
    this.emit('progress', { taskId, stage: 'executing' });

    // Send the initial message with system prompt injected directly.
    // OpenCode 1.1.48 ignores custom agents, so we bypass agent resolution
    // by passing the system prompt via the `system` field on sendMessage.
    // Also pass the model directly per-message to bypass config-based model
    // resolution, which fails for models not in OpenCode's models.dev database.
    managed.status = 'active';
    const messageModel = parseModelId(modelId);
    // Fire-and-forget: session lifecycle is managed entirely via SSE events.
    // Awaiting would cause a false "failed" status when the HTTP timeout
    // fires on long-running agent turns (the session keeps running on the server).
    this.client
      .sendMessage(session.id, {
        parts: [{ type: 'text', text: prompt }],
        directory: workingDirectory,
        system: buildSystemPrompt(this.serverPort, this.serverPassword, workingDirectory ?? '.', customPrompt),
        model: messageModel,
      })
      .catch((err) => {
        const msg = err instanceof Error ? err.message : String(err);
        logger.warn('sendMessage request failed (session may still be active via SSE)', {
          taskId,
          sessionId: session.id,
          error: msg,
        });
      });
  }

  async resumeSession(payload: ResumeSessionPayload): Promise<void> {
    const { taskId, sessionId, prompt, workingDirectory, modelId, folderPermissions, customPrompt, mcpServers, skipConfig, arenaId } =
      payload;

    logger.info('Resuming session', { taskId, sessionId, arenaId });

    // Push session-specific config via PATCH /config (skip if arena already sent it)
    if (!skipConfig) {
      const config = buildSessionConfig({ modelId, folderPermissions, mcpServers });
      await this.client.updateConfig(config, workingDirectory);
    }

    this.emit('progress', { taskId, stage: 'configuring' });

    // Get existing session
    const session = await this.client.getSession(sessionId, workingDirectory);

    const managed: ManagedSession = {
      taskId,
      sessionId,
      session,
      status: 'starting',
      textAccumulator: '',
      consecutiveCompactions: 0,
    };

    this.sessions.set(taskId, managed);
    this.sessionToTask.set(sessionId, taskId);

    this.emit('started', { taskId, sessionId });
    this.emit('progress', { taskId, stage: 'executing' });

    // Send follow-up message if provided, with system prompt injected directly.
    // Also pass the model directly per-message (same as startTask).
    if (prompt) {
      managed.status = 'active';
      managed.consecutiveCompactions = 0;
      const messageModel = parseModelId(modelId);
      // Fire-and-forget: same rationale as startTask() above.
      this.client
        .sendMessage(sessionId, {
          parts: [{ type: 'text', text: prompt }],
          directory: workingDirectory,
          system: buildSystemPrompt(this.serverPort, this.serverPassword, workingDirectory ?? '', customPrompt),
          model: messageModel,
        })
        .catch((err) => {
          const msg = err instanceof Error ? err.message : String(err);
          logger.warn('sendMessage request failed (session may still be active via SSE)', {
            taskId,
            sessionId,
            error: msg,
          });
        });
    }
  }

  async abortSession(taskId: string, sessionId: string): Promise<void> {
    logger.info('Aborting session', { taskId, sessionId });

    const managed = this.sessions.get(taskId);
    if (managed) {
      managed.status = 'completing';
    }

    const directory = managed?.session?.directory;
    await this.client.abortSession(sessionId, directory);

    this.emit('complete', {
      taskId,
      sessionId,
      status: 'aborted',
    });

    this.cleanup(taskId);
  }

  async replyToPermission(taskId: string, requestId: string, reply: 'once' | 'always' | 'reject', message?: string): Promise<void> {
    const managed = this.sessions.get(taskId);
    const directory = managed?.session?.directory;
    logger.info('Replying to permission', { taskId, requestId, reply, directory });
    await this.client.replyToPermission(requestId, reply, { message, directory });
  }

  async replyToQuestion(taskId: string, requestId: string, answers: Array<{ labels: string[]; customText?: string }>): Promise<void> {
    const managed = this.sessions.get(taskId);
    const directory = managed?.session?.directory;
    // OpenCode server expects each answer as a flat string[] of selected labels,
    // not {labels, customText} objects. Transform before sending.
    const flatAnswers: string[][] = answers.map((a) => {
      if (a.customText) return [...a.labels, a.customText];
      return a.labels;
    });
    logger.info('Replying to question', { taskId, requestId, answers: flatAnswers, directory });
    await this.client.replyToQuestion(requestId, flatAnswers, directory);
  }

  /** Number of sessions currently tracked (started and not yet completed/cleaned up). */
  activeSessionCount(): number {
    return this.sessions.size;
  }

  private cleanup(taskId: string): void {
    const managed = this.sessions.get(taskId);
    if (managed) {
      this.sessionToTask.delete(managed.sessionId);
      this.sessions.delete(taskId);
    }
  }

  dispose(): void {
    this.sessions.clear();
    this.sessionToTask.clear();
    this.removeAllListeners();
  }
}
