/**
 * Test helpers for integration tests.
 *
 * Each test gets a clean DB (tables truncated before the test), a mock
 * FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
 * AI SDK model factory (doGenerate() returns canned responses - no network).
 */
import { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import type {
  LanguageModelV4,
  LanguageModelV4CallOptions,
  LanguageModelV4Content,
  LanguageModelV4GenerateResult,
  LanguageModelV4StreamPart,
  LanguageModelV4StreamResult,
} from "@ai-sdk/provider";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";

export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
export const DEFAULT_ORG_ID = "org_test_default";
export const TEST_SECRET_KEY_ID = "test-active";
export const TEST_SECRET_KEY = Buffer.alloc(32, "k");
export const testSecretEnvelope = new LocalSecretEnvelope({
  activeKeyId: TEST_SECRET_KEY_ID,
  keys: new Map([[TEST_SECRET_KEY_ID, TEST_SECRET_KEY]]),
});

export const prisma = new PrismaClient({
  datasources: { db: { url: TEST_DATABASE_URL } },
});

/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
  // User and Organization are the aggregate roots for all domain rows; their
  // declared FK cascades clear projects, search documents, permissions,
  // sessions and connections without repeatedly truncating pg_trgm indexes.
  // Event receipts and global audit rows are independent roots.
  await prisma.$transaction([
    prisma.feishuEventReceipt.deleteMany(),
    prisma.auditEntry.deleteMany(),
    // Permission resource ids are intentionally polymorphic strings, so these
    // two tables have no FK to Project and must be cleared explicitly.
    prisma.permissionGrant.deleteMany(),
    prisma.permissionSettings.deleteMany(),
    prisma.user.deleteMany(),
    prisma.organization.deleteMany(),
  ]);
  await seedTestOrganization();
}

export async function seedTestOrganization(
  id: string = DEFAULT_ORG_ID,
  slug: string = "test-default",
): Promise<void> {
  await prisma.$transaction(async (tx) => {
    await tx.organization.upsert({
      where: { id },
      update: {},
      create: { id, slug, name: "Test Default Organization" },
    });
    await tx.organizationProjectSettings.upsert({
      where: { organizationId: id },
      update: {},
      create: { organizationId: id, membersCanCreateProjects: true },
    });
    const defaultRole = await tx.organizationAgentRole.upsert({
      where: { organizationId_roleId: { organizationId: id, roleId: "draft" } },
      update: { label: "草稿", isDefault: true, disabledAt: null },
      create: {
        id: `agent_role_draft_${id}`,
        organizationId: id,
        roleId: "draft",
        label: "草稿",
        sortOrder: 10,
        isDefault: true,
      },
    });
    await tx.organizationAgentRole.updateMany({
      where: { organizationId: id, id: { not: defaultRole.id }, isDefault: true },
      data: { isDefault: false },
    });
  });
  const inbox = await prisma.folder.findFirst({
    where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
    select: { id: true },
  });
  if (inbox === null) {
    await prisma.folder.create({
      data: {
        id: `folder_inbox_${id}`,
        organizationId: id,
        name: "Inbox",
        kind: "SYSTEM_INBOX",
        sortKey: "000000",
      },
    });
  }
}

/** A logger that discards everything (tests don't need fastify's pino). */
export const silentLogger: FastifyBaseLogger = {
  info() {}, warn() {}, error() {}, debug() {}, fatal() {},
  child() { return this; },
  level: "silent",
} as unknown as FastifyBaseLogger;

/** Records sendText/sendCard calls so tests can assert on them. */
export interface MockFeishuRuntime extends FeishuRuntime {
  readonly sentTexts: string[];
  readonly sentCards: unknown[];
  readonly sentPatches: unknown[];
  readonly sentReplies: Array<{
    readonly messageId: string;
    readonly msgType: string;
    readonly content: string;
    readonly replyInThread: unknown;
  }>;
  readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>;
  readonly readableMessages: Map<string, MockFeishuMessage>;
  readonly threadMessages: Map<string, readonly MockFeishuMessage[]>;
}

export interface MockFeishuMessage {
  readonly message_id?: string;
  readonly root_id?: string;
  readonly parent_id?: string;
  readonly thread_id?: string;
  readonly msg_type?: string;
  readonly create_time?: string;
  readonly chat_id?: string;
  readonly sender?: { readonly id?: string; readonly sender_type?: string };
  readonly body?: { readonly content?: string };
  readonly content?: string;
}

export function mockFeishuRuntime(): MockFeishuRuntime {
  const sentTexts: string[] = [];
  const sentCards: unknown[] = [];
  const sentPatches: unknown[] = [];
  const sentReplies: MockFeishuRuntime["sentReplies"] = [];
  const reactions: Array<{ messageId: string; emoji: string }> = [];
  const readableMessages = new Map<string, MockFeishuMessage>();
  const threadMessages = new Map<string, readonly MockFeishuMessage[]>();
  // Mock the client — sendText/sendCard are in client.ts, not on the runtime
  // object directly. We patch by providing a runtime whose client is a stub;
  // the actual send functions cast through shape, so a minimal stub works.
  const rt: MockFeishuRuntime = {
    client: {
      request: async (p: unknown) => {
        const payload = p as { url?: string; data?: { reaction_type?: { emoji_type?: string } } };
        const match = payload.url?.match(/\/messages\/([^/]+)\/reactions$/);
        if (match?.[1] !== undefined) {
          reactions.push({
            messageId: match[1],
            emoji: payload.data?.reaction_type?.emoji_type ?? "",
          });
        }
        return {};
      },
      im: {
        v1: {
          message: {
            create: async (p: unknown) => {
              const payload = p as { data?: { msg_type?: string; content?: string } };
              recordSentMessage(payload.data, sentTexts, sentCards);
              return { data: { message_id: "mock-msg-id" } };
            },
            reply: async (p: unknown) => {
              const payload = p as {
                path?: { message_id?: string };
                data?: { msg_type?: string; content?: string; reply_in_thread?: unknown };
              };
              sentReplies.push({
                messageId: payload.path?.message_id ?? "",
                msgType: payload.data?.msg_type ?? "",
                content: payload.data?.content ?? "",
                replyInThread: payload.data?.reply_in_thread,
              });
              recordSentMessage(payload.data, sentTexts, sentCards);
              return { data: { message_id: "mock-msg-id" } };
            },
            patch: async (p: unknown) => {
              const payload = p as { data?: { content?: string } };
              const card = payload.data?.content ? JSON.parse(payload.data.content) : null;
              sentPatches.push(card);
              const text = textFromCard(card);
              if (text !== null) sentTexts.push(text);
              return {};
            },
            get: async (p: unknown) => {
              const payload = p as { path?: { message_id?: string } };
              const messageId = payload.path?.message_id ?? "";
              const message = readableMessages.get(messageId);
              return { data: { items: message === undefined ? [] : [message] } };
            },
            list: async (p: unknown) => {
              const payload = p as { params?: { container_id?: string } };
              const containerId = payload.params?.container_id ?? "";
              const items =
                threadMessages.get(containerId) ??
                [...readableMessages.values()].filter((message) => message.parent_id === containerId);
              return { data: { items } };
            },
          },
        },
      },
    } as unknown as FeishuRuntime["client"],
    logger: silentLogger,
    sentTexts,
    sentCards,
    sentPatches,
    sentReplies,
    reactions,
    readableMessages,
    threadMessages,
  };
  return rt;
}

function recordSentMessage(
  data: { msg_type?: string; content?: string } | undefined,
  sentTexts: string[],
  sentCards: unknown[],
): void {
  if (data?.msg_type === "interactive") {
    const card = data.content ? JSON.parse(data.content) : null;
    sentCards.push(card);
    const text = textFromCard(card);
    if (text !== null) sentTexts.push(text);
    return;
  }

  try {
    const c = JSON.parse(data?.content ?? "{}") as { text?: string };
    sentTexts.push(c.text ?? data?.content ?? "");
  } catch {
    sentTexts.push(data?.content ?? "");
  }
}

function textFromCard(card: unknown): string | null {
  if (typeof card !== "object" || card === null) return null;
  const elements = (card as { elements?: unknown }).elements;
  if (!Array.isArray(elements)) return null;
  const parts: string[] = [];
  for (const element of elements) {
    if (typeof element !== "object" || element === null) continue;
    // New format: { tag: "markdown", content: string }
    const tag = (element as { tag?: unknown }).tag;
    const directContent = (element as { content?: unknown }).content;
    if (tag === "markdown" && typeof directContent === "string") {
      parts.push(directContent);
      continue;
    }
    // Old format: { text: { content: string } }
    const text = (element as { text?: unknown }).text;
    if (typeof text === "object" && text !== null) {
      const content = (text as { content?: unknown }).content;
      if (typeof content === "string") parts.push(content);
    }
  }
  return parts.length === 0 ? null : parts.join("\n");
}

export interface MockToolCall {
  readonly toolCallId: string;
  readonly toolName: string;
  readonly input: unknown;
}

export interface MockModelResponse {
  readonly text?: string;
  readonly toolCalls?: readonly MockToolCall[];
  readonly finishReason?: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
  readonly inputTokens?: number;
  readonly outputTokens?: number;
}

export class MockLanguageModel implements LanguageModelV4 {
  readonly specificationVersion = "v4";
  readonly provider = "mock";
  readonly supportedUrls: Record<string, RegExp[]> = {};
  readonly calls: LanguageModelV4CallOptions[] = [];

  constructor(
    readonly modelId: string,
    private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }],
  ) {}

  async doGenerate(options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult> {
    this.calls.push(options);
    const configured = this.responses[this.calls.length - 1];
    const last = this.responses[this.responses.length - 1];
    const response =
      configured ??
      ((last?.toolCalls?.length ?? 0) > 0
        ? { text: "mock response" }
        : last ?? { text: "mock response" });
    const content: LanguageModelV4Content[] = [];
    if (response.text !== undefined) {
      content.push({ type: "text", text: response.text });
    }
    for (const call of response.toolCalls ?? []) {
      content.push({
        type: "tool-call",
        toolCallId: call.toolCallId,
        toolName: call.toolName,
        input: JSON.stringify(call.input),
      });
    }
    const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
    return {
      content,
      finishReason: { unified: finishReason, raw: finishReason },
      usage: {
        inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
        outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
      },
      response: { id: `mock-${this.calls.length}`, timestamp: new Date(), modelId: this.modelId },
      warnings: [],
    };
  }

  async doStream(options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
    this.calls.push(options);
    const response = this.responses[this.calls.length - 1 % this.responses.length] ?? this.responses[0]!;
    const text = response.text ?? "";
    const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop");
    const id = `mock-${this.calls.length}`;

    const parts: LanguageModelV4StreamPart[] = [
      { type: "text-start", id },
    ];
    if (text.length > 0) {
      parts.push({ type: "text-delta", id, delta: text });
    }
    parts.push({ type: "text-end", id });
    for (const call of response.toolCalls ?? []) {
      const tcId = `tc-${this.calls.length}-${call.toolName}`;
      parts.push({ type: "tool-input-start", id: tcId, toolName: call.toolName });
      parts.push({ type: "tool-input-delta", id: tcId, delta: JSON.stringify(call.input) });
      parts.push({ type: "tool-input-end", id: tcId });
    }
    parts.push({
      type: "finish",
      finishReason: { unified: finishReason, raw: finishReason },
      usage: {
        inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
        outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined },
      },
    });

    return {
      stream: new ReadableStream({
        start(controller) {
          for (const part of parts) controller.enqueue(part);
          controller.close();
        },
      }),
      response: { id, timestamp: new Date(), modelId: "mock-model" },
    };
  }
}

export function createMockModelFactory(
  responses: readonly MockModelResponse[] = [{ text: "mock response" }],
): { readonly modelFactory: ModelFactory; readonly models: ReadonlyMap<string, MockLanguageModel> } {
  const models = new Map<string, MockLanguageModel>();
  return {
    models,
    modelFactory: (modelId) => {
      const model = new MockLanguageModel(modelId, responses);
      models.set(modelId, model);
      return model;
    },
  };
}

/** Create a project + binding + user + grant for a test. */
export async function seedProject(
  projectId: string,
  chatId: string,
  options: { principal?: string; role?: "READ" | "EDIT" | "MANAGE" } = {},
): Promise<void> {
  const principal = options.principal ?? "ou_test_user";
  const role = options.role ?? "EDIT";
  await prisma.project.create({
    data: {
      id: projectId,
      organizationId: DEFAULT_ORG_ID,
      name: `Test ${projectId}`,
      workspaceDir: `/tmp/test-${projectId}`,
    },
  });
  await prisma.user.create({
    data: {
      id: "u_" + projectId,
      feishuOpenId: principal,
      displayName: "Test User",
      organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
      permissionGrants: {
        create: {
          resourceType: "PROJECT",
          resourceId: projectId,
          principalType: "USER",
          principalId: principal,
          role,
        },
      },
    },
  });
  await prisma.projectGroupBinding.create({
    data: {
      organizationId: DEFAULT_ORG_ID,
      projectId,
      chatId,
      createdByUserId: "u_" + projectId,
      selectedAgentRoleId: `agent_role_draft_${DEFAULT_ORG_ID}`,
    },
  });
}
