/**
 * mi6 plugin for OpenCode
 *
 * This plugin integrates OpenCode with mi6 for session monitoring and analytics.
 * It captures various events and forwards them to mi6 via the CLI.
 *
 * Installation: This file is automatically installed by `mi6 enable opencode`
 * to ~/.config/opencode/plugin/mi6.ts
 *
 * Environment variables:
 * - MI6_BIN: Path to mi6 binary (default: searches PATH, then common locations)
 * - MI6_OTEL_PORT: Port for OTEL server (default: 4318). Set to start OTEL server on session start.
 *
 * @see https://github.com/paradigmxyz/mi6
 */

// OTEL server port (default: 4318). Server starts automatically on session start.
// Set MI6_OTEL_PORT="" to disable automatic OTEL server startup.
const MI6_OTEL_PORT = process.env.MI6_OTEL_PORT ?? "4318";

// Track if OTEL server has been started this session
let otelStarted = false;

// Track user message metadata (for capturing prompt text from message.part.updated)
const pendingUserMessages = new Map<string, Record<string, unknown>>();

// Track pending text parts that arrived before their message.updated event
// This handles the case where message.part.updated arrives before message.updated (e.g., first message)
const pendingTextParts = new Map<string, string>();

// Track seen message IDs to deduplicate events
// Note: Sets are cleared when they exceed 5000 entries to prevent unbounded memory growth
const seenUserMsgIds = new Set<string>();
const seenAssistantMsgIds = new Set<string>();
const MAX_SEEN_IDS = 5000;

/** Clear deduplication sets if they grow too large */
const maybeCleanupSets = () => {
  if (seenUserMsgIds.size > MAX_SEEN_IDS) seenUserMsgIds.clear();
  if (seenAssistantMsgIds.size > MAX_SEEN_IDS) seenAssistantMsgIds.clear();
  // Clean up orphaned pending messages/text parts (limit to 1000 entries)
  if (pendingUserMessages.size > 1000) pendingUserMessages.clear();
  if (pendingTextParts.size > 1000) pendingTextParts.clear();
};

/**
 * mi6 plugin for OpenCode session monitoring.
 *
 * Uses the standard OpenCode plugin format with a single event handler
 * that dispatches based on event.type.
 */
export const Mi6Plugin = async ({ $ }: { $: any }) => {
  const fs = await import("fs");
  const os = await import("os");
  const path = await import("path");

  // Find mi6 binary - check env var, then common locations
  const findMi6 = (): string => {
    if (process.env.MI6_BIN) {
      return process.env.MI6_BIN;
    }
    // Common installation locations
    const home = os.homedir();
    const candidates = [
      path.join(home, ".cargo/bin/mi6"),
      path.join(home, ".local/bin/mi6"),
      "/usr/local/bin/mi6",
      "/opt/homebrew/bin/mi6",
      "mi6", // fallback to PATH
    ];
    for (const candidate of candidates) {
      if (candidate === "mi6" || fs.existsSync(candidate)) {
        return candidate;
      }
    }
    return "mi6";
  };

  const MI6_BIN = findMi6();

  /**
   * Normalize event payload to ensure common fields are at top level.
   * OpenCode uses different structures for different events.
   */
  const normalizePayload = (event: unknown): Record<string, unknown> => {
    const raw = event as Record<string, unknown>;
    const normalized: Record<string, unknown> = { ...raw };
    const info = raw.info as Record<string, unknown> | undefined;

    // Extract session ID from various nested locations
    // Note: info.id can be message ID (msg_...) or session ID (ses_...) depending on event type
    // Check sessionID fields first, then fall back to id only if it looks like a session ID
    if (!normalized.sessionId && !normalized.session_id) {
      if (info?.sessionID) {
        normalized.sessionId = info.sessionID;
      } else if (raw.sessionID) {
        normalized.sessionId = raw.sessionID;
      } else if (info?.id && typeof info.id === "string" && info.id.startsWith("ses_")) {
        // Only use info.id if it's actually a session ID (not a message ID)
        normalized.sessionId = info.id;
      }
    }

    // Extract cwd from nested structures
    if (!normalized.cwd) {
      const infoPath = info?.path as Record<string, unknown> | undefined;
      if (infoPath?.cwd) {
        normalized.cwd = infoPath.cwd;
      } else if (info?.directory) {
        normalized.cwd = info.directory;
      }
    }

    // Extract model from nested structures
    if (!normalized.model) {
      const infoModel = info?.model as Record<string, unknown> | undefined;
      if (infoModel?.modelID) {
        normalized.model = infoModel.modelID;
      } else if (info?.modelID) {
        normalized.model = info.modelID;
      } else if (raw.modelID) {
        normalized.model = raw.modelID;
      }
    }

    // Extract token usage from nested structures
    const tokens = info?.tokens as Record<string, unknown> | undefined;
    if (tokens) {
      if (tokens.input !== undefined) normalized.tokens_input = tokens.input;
      if (tokens.output !== undefined) normalized.tokens_output = tokens.output;
      const cache = tokens.cache as Record<string, unknown> | undefined;
      if (cache?.read !== undefined) normalized.tokens_cache_read = cache.read;
      if (cache?.write !== undefined) normalized.tokens_cache_write = cache.write;
    }

    // Extract cost from nested structures
    if (info?.cost !== undefined) {
      normalized.cost_usd = info.cost;
    }

    return normalized;
  };

  /**
   * Helper to ingest an event into mi6.
   *
   * Uses temp file to pass JSON payload because Bun's shell ($`...`) doesn't support
   * the .stdin() method that Node's shell libraries provide. This is a known Bun
   * limitation - see: https://github.com/oven-sh/bun/issues/4950
   */
  const ingestEvent = async (eventType: string, event: unknown) => {
    maybeCleanupSets(); // Prevent unbounded memory growth
    const tmpFile = path.join(os.tmpdir(), `mi6-payload-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
    try {
      const normalized = normalizePayload(event);
      const payload = JSON.stringify(normalized);
      fs.writeFileSync(tmpFile, payload);
      await $`cat ${tmpFile} | ${MI6_BIN} ingest event ${eventType} --framework opencode`.quiet();
    } catch {
      // Silently ignore errors to not disrupt OpenCode
    } finally {
      // Clean up temp file
      try {
        fs.unlinkSync(tmpFile);
      } catch {
        // Ignore cleanup errors
      }
    }
  };

  /**
   * Start OTEL server if configured (idempotent, runs once per session)
   */
  const maybeStartOtel = async () => {
    if (MI6_OTEL_PORT && !otelStarted) {
      otelStarted = true;
      try {
        await $`${MI6_BIN} otel start --port ${MI6_OTEL_PORT}`.quiet();
      } catch {
        // Ignore OTEL server start errors (may already be running)
      }
    }
  };

  return {
    /**
     * Unified event handler for all OpenCode events.
     * Dispatches to mi6 based on event.type.
     */
    event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
      const eventType = event.type;
      // OpenCode wraps event data in `properties` for some events; fall back to full event object
      const payload = event.properties ?? event;

      // Capture sessionId from event object - may be at top level, not in properties
      // This is critical for message events where sessionId is not in the properties payload
      const eventObj = event as Record<string, unknown>;
      const eventSessionId = eventObj.sessionId ?? eventObj.sessionID ?? eventObj.session_id;

      switch (eventType) {
        // Session lifecycle events
        case "session.created":
          await maybeStartOtel();
          await ingestEvent("SessionStart", payload);
          break;

        case "session.idle":
          await ingestEvent("Stop", payload);
          break;

        case "session.compacted":
          await ingestEvent("PreCompact", payload);
          break;

        case "session.deleted":
          await ingestEvent("SessionEnd", payload);
          break;

        case "session.error":
          await ingestEvent("SessionError", payload);
          break;

        // Session status changes - capture when OpenCode reports status transitions
        case "session.status": {
          const status = (payload as Record<string, unknown>)?.status as string | undefined;
          // When status is "busy" or "running", send UserPromptSubmit to set status
          // When status is "idle", send Stop to set status
          if (status === "busy" || status === "running" || status === "processing") {
            const enrichedPayload = {
              ...(payload as Record<string, unknown>),
              ...(eventSessionId && { sessionId: eventSessionId }),
            };
            await ingestEvent("UserPromptSubmit", enrichedPayload);
          } else if (status === "idle") {
            await ingestEvent("Stop", payload);
          }
          break;
        }

        // Message events
        case "message.updated": {
          const info = (payload as Record<string, unknown>)?.info as Record<string, unknown> | undefined;
          const msgId = info?.id as string | undefined;
          const role = info?.role as string | undefined;

          if (role === "user" && msgId && !seenUserMsgIds.has(msgId)) {
            // Check if we already have text for this message (arrived before metadata)
            const pendingText = pendingTextParts.get(msgId);
            if (pendingText) {
              // Text arrived first - we can now send UserPromptSubmit
              // Include sessionId from event object since it may not be in properties
              seenUserMsgIds.add(msgId);
              const enrichedPayload = {
                ...(payload as Record<string, unknown>),
                prompt: pendingText,
                ...(eventSessionId && { sessionId: eventSessionId }),
              };
              await ingestEvent("UserPromptSubmit", enrichedPayload);
              pendingTextParts.delete(msgId);
            } else {
              // Store metadata and wait for text
              // Include sessionId from event object so it's available when we send UserPromptSubmit later
              pendingUserMessages.set(msgId, {
                ...(payload as Record<string, unknown>),
                ...(eventSessionId && { sessionId: eventSessionId }),
              });
            }
          } else if (role === "assistant" && msgId) {
            // For assistant messages, capture token/cost info (once per message)
            const tokens = info?.tokens as Record<string, unknown> | undefined;
            if (tokens?.output && !seenAssistantMsgIds.has(msgId)) {
              seenAssistantMsgIds.add(msgId);
              await ingestEvent("ApiRequest", payload);
            }
          }
          break;
        }

        // Message part events - capture prompt text for user messages
        case "message.part.updated": {
          const part = (payload as Record<string, unknown>)?.part as Record<string, unknown> | undefined;
          const msgId = part?.messageID as string | undefined;
          const text = part?.text as string | undefined;

          // Skip if already processed or no valid data
          if (!msgId || !text || seenUserMsgIds.has(msgId)) break;

          // Check if we have metadata for this message
          if (pendingUserMessages.has(msgId)) {
            // Metadata arrived first - we can send UserPromptSubmit now
            seenUserMsgIds.add(msgId);
            const userMsgData = pendingUserMessages.get(msgId)!;
            const enrichedPayload = { ...userMsgData, prompt: text };
            await ingestEvent("UserPromptSubmit", enrichedPayload);
            pendingUserMessages.delete(msgId);
          } else {
            // Text arrived before metadata - store and wait for message.updated
            pendingTextParts.set(msgId, text);
          }
          break;
        }

        // Tool execution events
        case "tool.execute.before":
          await ingestEvent("PreToolUse", payload);
          break;

        case "tool.execute.after":
          await ingestEvent("PostToolUse", payload);
          break;

        // Permission events
        case "permission.updated":
        case "permission.replied":
          await ingestEvent("PermissionRequest", payload);
          break;

        // File events
        case "file.edited":
          await ingestEvent("FileEdited", payload);
          break;

        // Command events
        case "command.executed":
          await ingestEvent("CommandExecuted", payload);
          break;

        // Ignore other events (session.updated, session.status, etc.)
        default:
          break;
      }
    },
  };
};
