import {
  createOpencode,
  createOpencodeClient,
  type OpencodeClient,
} from "@opencode-ai/sdk/v2";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";

import { config } from "../config.js";
import { logger } from "../logger.js";

const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";

const logDevelopmentDebug = (
  message: string,
  metadata: Record<string, unknown>,
) => {
  if (!isDevelopmentDebugLoggingEnabled) {
    return;
  }
  logger.info(metadata, message);
};

export type RuntimeHealth = {
  healthy: boolean;
  version: string;
};

type RuntimeModelOverride = {
  providerID: string;
  modelID: string;
};

export type PermissionReply = "once" | "always" | "reject";
export type QuestionAnswers = string[][];

type RuntimeMessage = {
  info: {
    id: string;
    role: string;
  };
};

const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role;
const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id;

export class OpencodeRuntimeAdapter {
  private clientPromise: Promise<OpencodeClient> | null = null;
  private closeServer: (() => void) | null = null;

  async ensureClient(): Promise<OpencodeClient> {
    if (!this.clientPromise) {
      this.clientPromise = this.bootstrapClient().catch((error) => {
        this.clientPromise = null;
        throw error;
      });
    }
    return this.clientPromise;
  }

  async health(): Promise<RuntimeHealth> {
    const client = await this.ensureClient();
    const response = await client.global.health();
    return requireData(response.data, "global.health");
  }

  async createSession(title?: string) {
    const client = await this.ensureClient();
    const response = await client.session.create({
      title,
    });
    return requireData(response.data, "session.create");
  }

  async sendPrompt(sessionId: string, text: string) {
    await this.prompt(sessionId, text);
    // 当前 SDK 响应风格下，prompt() 本身不会直接返回完整 assistant parts，
    // 所以这里紧跟一次 messages() 回读，给上层路由统一消费。
    return this.messages(sessionId);
  }

  async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
    const client = await this.ensureClient();
    const startedAt = Date.now();
    logDevelopmentDebug(
      "dispatching opencode session.prompt",
      {
        sessionId,
        model: model ?? null,
        textChars: text.length,
      },
    );
    await client.session.prompt({
      sessionID: sessionId,
      model,
      parts: [{ type: "text", text }],
    });
    logDevelopmentDebug(
      "opencode session.prompt returned",
      {
        sessionId,
        elapsedMs: Math.max(0, Date.now() - startedAt),
      },
    );
  }

  async messages(sessionId: string, limit = 20) {
    const client = await this.ensureClient();
    const messages = await client.session.messages({
      sessionID: sessionId,
      limit,
    });
    return requireData(messages.data, "session.messages");
  }

  async revertMessage(sessionId: string, messageId: string) {
    const client = await this.ensureClient();
    const response = await client.session.revert({
      sessionID: sessionId,
      messageID: messageId,
    });
    return response.data;
  }

  async removeMessage(sessionId: string, messageId: string) {
    const client = await this.ensureClient();
    const response = await client.session.deleteMessage({
      sessionID: sessionId,
      messageID: messageId,
    });
    return response.data;
  }

  async revertToUserMessage(sessionId: string, options: { userOrdinal: number }) {
    const messages = await this.messages(sessionId, 80);
    const userMessages = messages.filter(
      (message) => getRuntimeMessageRole(message) === "user",
    );
    const targetUserMessage = userMessages[options.userOrdinal - 1];

    if (!targetUserMessage) {
      if (messages.length === 0 && options.userOrdinal === 1) {
        logger.warn(
          { sessionId, userOrdinal: options.userOrdinal },
          "skipping opencode revert because runtime session has no messages",
        );
        return;
      }
      throw new Error("target user message not found to revert");
    }

    const targetMessageId = getRuntimeMessageId(targetUserMessage);
    const targetIndex = messages.findIndex(
      (message) => getRuntimeMessageId(message) === targetMessageId,
    );
    const messagesToRemove = targetIndex >= 0 ? messages.slice(targetIndex) : [targetUserMessage];

    await this.revertMessage(sessionId, targetMessageId);

    for (const message of messagesToRemove.reverse()) {
      const messageId = getRuntimeMessageId(message);
      try {
        await this.removeMessage(sessionId, messageId);
      } catch (error) {
        logger.warn(
          { err: error, sessionId, messageId },
          "failed to remove reverted opencode message",
        );
      }
    }
  }

  async abortSession(sessionId: string) {
    const client = await this.ensureClient();
    const response = await client.session.abort({
      sessionID: sessionId,
    });
    return requireData(response.data, "session.abort");
  }

  async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) {
    const client = await this.ensureClient();
    const startedAt = Date.now();

    while (Date.now() - startedAt < timeoutMs) {
      const response = await client.session.status({});
      const statuses = requireData(response.data, "session.status");
      const status = statuses[sessionId];
      if (!status || status.type === "idle") {
        return;
      }
      await delay(100);
    }

    logger.warn(
      { sessionId, timeoutMs },
      "timed out waiting for opencode session to become idle",
    );
  }

  async subscribeEvents() {
    const client = await this.ensureClient();
    const response = await client.event.subscribe();
    return response.stream;
  }

  async replyPermission(options: {
    requestId: string;
    sessionId?: string;
    reply: PermissionReply;
    message?: string;
  }) {
    const client = await this.ensureClient();
    if ("permission" in client && client.permission?.reply) {
      const response = await client.permission.reply({
        requestID: options.requestId,
        reply: options.reply,
        message: options.message,
      });
      return response.data;
    }

    if ("permission" in client && client.permission?.respond && options.sessionId) {
      const response = await client.permission.respond({
        sessionID: options.sessionId,
        permissionID: options.requestId,
        response: options.reply,
      });
      return response.data;
    }

    throw new Error("opencode permission reply API is unavailable");
  }

  async replyQuestion(options: {
    requestId: string;
    sessionId?: string;
    answers: QuestionAnswers;
  }) {
    const client = await this.ensureClient();
    if ("question" in client && client.question?.reply) {
      try {
        const response = await client.question.reply({
          requestID: options.requestId,
          answers: options.answers,
        });
        return response.data;
      } catch (error) {
        if (!options.sessionId) {
          throw error;
        }
      }
    }

    const v2Question = (client as unknown as {
      v2?: {
        session?: {
          question?: {
            reply?: (parameters: {
              sessionID: string;
              requestID: string;
              questionV2Reply: { answers: QuestionAnswers };
            }) => Promise<{ data: unknown }>;
          };
        };
      };
    }).v2?.session?.question;

    if (v2Question?.reply && options.sessionId) {
      const response = await v2Question.reply({
        sessionID: options.sessionId,
        requestID: options.requestId,
        questionV2Reply: {
          answers: options.answers,
        },
      });
      return response.data;
    }

    throw new Error("opencode question reply API is unavailable");
  }

  async rejectQuestion(options: {
    requestId: string;
    sessionId?: string;
  }) {
    const client = await this.ensureClient();
    if ("question" in client && client.question?.reject) {
      try {
        const response = await client.question.reject({
          requestID: options.requestId,
        });
        return response.data;
      } catch (error) {
        if (!options.sessionId) {
          throw error;
        }
      }
    }

    const v2Question = (client as unknown as {
      v2?: {
        session?: {
          question?: {
            reject?: (parameters: {
              sessionID: string;
              requestID: string;
            }) => Promise<{ data: unknown }>;
          };
        };
      };
    }).v2?.session?.question;

    if (v2Question?.reject && options.sessionId) {
      const response = await v2Question.reject({
        sessionID: options.sessionId,
        requestID: options.requestId,
      });
      return response.data;
    }

    throw new Error("opencode question reject API is unavailable");
  }

  async dispose(): Promise<void> {
    this.closeServer?.();
    this.closeServer = null;
    this.clientPromise = null;
  }

  private async bootstrapClient(): Promise<OpencodeClient> {
    if (config.OPENCODE_MODE === "client") {
      logger.info(
        {
          baseUrl: config.OPENCODE_CLIENT_BASE_URL,
          mode: config.OPENCODE_MODE,
        },
        "connecting to opencode server in client mode",
      );
      return createOpencodeClient({
        baseUrl: config.OPENCODE_CLIENT_BASE_URL,
      });
    }

    // embedded 模式下，把服务内工具桥地址注入到 opencode 进程环境里，
    // 这样 .opencode/tools 下的自定义工具可以回调本服务。
    process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
    process.env.TJWATER_AGENT_INTERNAL_TOKEN =
      config.AGENT_INTERNAL_TOKEN ??
      process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
      "";

    logger.info(
      {
        hostname: config.OPENCODE_HOSTNAME,
        port: config.OPENCODE_PORT,
        model: config.OPENCODE_MODEL,
        mode: config.OPENCODE_MODE,
      },
      "starting opencode server in embedded mode",
    );

    const startedAt = Date.now();
    let runtime;
    try {
      runtime = await createOpencode({
        hostname: config.OPENCODE_HOSTNAME,
        port: config.OPENCODE_PORT,
        timeout: config.OPENCODE_TIMEOUT_MS,
        config: buildOpencodeConfig(),
      });
    } catch (error) {
      if (isMissingOpencodeCli(error)) {
        throw new Error(
          "embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL",
        );
      }
      throw error;
    }

    logger.info(
      {
        elapsedMs: Math.max(0, Date.now() - startedAt),
        hostname: config.OPENCODE_HOSTNAME,
        port: config.OPENCODE_PORT,
        mode: config.OPENCODE_MODE,
      },
      "opencode server started in embedded mode",
    );

    this.closeServer = () => {
      runtime.server.close();
    };

    return runtime.client;
  }
}

export const opencodeRuntime = new OpencodeRuntimeAdapter();

function buildOpencodeConfig(): Record<string, unknown> {
  return deepMerge(
    deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()),
    {
      model: config.OPENCODE_MODEL,
    },
  );
}

function readProjectOpencodeConfig(): Record<string, unknown> {
  const path = resolve(process.cwd(), "opencode.json");
  if (!existsSync(path)) {
    return {};
  }
  return parseConfigJson(readFileSync(path, "utf8"), path);
}

function readEnvOpencodeConfig(): Record<string, unknown> {
  const content = process.env.OPENCODE_CONFIG_CONTENT;
  if (!content?.trim()) {
    return {};
  }
  return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT");
}

function parseConfigJson(content: string, source: string): Record<string, unknown> {
  const parsed = JSON.parse(content) as unknown;
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
    throw new Error(`${source} must contain a JSON object`);
  }
  return parsed as Record<string, unknown>;
}

function deepMerge(
  left: Record<string, unknown>,
  right: Record<string, unknown>,
): Record<string, unknown> {
  const next = { ...left };
  for (const [key, value] of Object.entries(right)) {
    const existing = next[key];
    if (isPlainObject(existing) && isPlainObject(value)) {
      next[key] = deepMerge(existing, value);
    } else {
      next[key] = value;
    }
  }
  return next;
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException {
  return (
    typeof error === "object" &&
    error !== null &&
    "code" in error &&
    (error as NodeJS.ErrnoException).code === "ENOENT"
  );
}

function requireData<T>(data: T | undefined, operation: string): T {
  if (data === undefined) {
    throw new Error(`${operation} returned no data`);
  }
  return data;
}

function delay(ms: number) {
  return new Promise<void>((resolve) => {
    setTimeout(resolve, ms);
  });
}
