// Discord tests cover model picker preferences migrations plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { buildLegacyMigrationPreview } from "openclaw/plugin-sdk/runtime-doctor-migrations";
import {
  resolvePreferredOpenClawTmpDir,
  tempWorkspace,
  type TempWorkspace,
} from "openclaw/plugin-sdk/temp-path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "../../doctor-contract-api.js";
import { detectDiscordLegacyStateMigrations } from "./model-picker-preferences-migrations.js";

let stateWorkspace: TempWorkspace;

beforeEach(async () => {
  stateWorkspace = await tempWorkspace({
    rootDir: resolvePreferredOpenClawTmpDir(),
    prefix: "openclaw-discord-model-picker-migration-",
  });
});

afterEach(async () => {
  await stateWorkspace.cleanup();
});

describe("Discord model picker preference migration", () => {
  it("plans legacy command deployment cache deletion without importing hashes", async () => {
    const stateDir = stateWorkspace.dir;
    const sourcePath = path.join(stateDir, "discord", "command-deploy-cache.json");
    await fs.mkdir(path.dirname(sourcePath), { recursive: true });
    await fs.writeFile(sourcePath, "{malformed cache", "utf8");

    const plans = await Promise.resolve(
      detectDiscordLegacyStateMigrations({
        cfg: {},
        env: {},
        oauthDir: path.join(stateDir, "credentials"),
        stateDir,
      }),
    );

    expect(plans).toHaveLength(1);
    const plan = plans?.[0];
    if (plan?.kind !== "plugin-state-import") {
      throw new Error("expected plugin-state import plan");
    }
    expect(plan).toMatchObject({
      label: "Discord command deployment cache",
      pluginId: "discord",
      namespace: "command-deploy-hashes",
      maxEntries: 10_000,
      cleanupSource: "remove",
      cleanupWhenEmpty: true,
    });
    expect(await plan.readEntries()).toEqual([]);
  });

  it("plans legacy JSON import into plugin state", async () => {
    const stateDir = stateWorkspace.dir;
    const sourcePath = path.join(stateDir, "discord", "model-picker-preferences.json");
    await fs.mkdir(path.dirname(sourcePath), { recursive: true });
    await fs.writeFile(
      sourcePath,
      JSON.stringify({
        version: 1,
        entries: {
          "discord:default:dm:user:123": {
            recent: ["OpenAI/gpt-5", "bad", "openai/gpt-5"],
            updatedAt: "2026-05-29T00:00:00.000Z",
          },
        },
      }),
    );

    const plans = await Promise.resolve(
      detectDiscordLegacyStateMigrations({
        cfg: {},
        env: {},
        oauthDir: path.join(stateDir, "credentials"),
        stateDir,
      }),
    );

    if (!plans) {
      throw new Error("expected migration plans");
    }
    expect(plans).toHaveLength(1);
    const plan = plans[0];
    expect(plan?.kind).toBe("plugin-state-import");
    if (plan?.kind !== "plugin-state-import") {
      throw new Error("expected plugin-state import plan");
    }
    expect(plan.pluginId).toBe("discord");
    expect(plan.namespace).toBe("model-picker-preferences");
    const entries = await plan.readEntries();
    expect(entries).toHaveLength(1);
    expect(entries[0]?.key).toMatch(/^v1:[0-9a-f]{32}:[0-9a-f]{24}$/u);
    expect(entries[0]?.value).toEqual({
      scopeKey: "discord:default:dm:user:123",
      modelRef: "openai/gpt-5",
      updatedAt: "2026-05-29T00:00:00.001Z",
    });
  });

  it("plans legacy JSON import with max Date timestamps", async () => {
    const stateDir = stateWorkspace.dir;
    const sourcePath = path.join(stateDir, "discord", "model-picker-preferences.json");
    await fs.mkdir(path.dirname(sourcePath), { recursive: true });
    await fs.writeFile(
      sourcePath,
      JSON.stringify({
        version: 1,
        entries: {
          "discord:default:dm:user:max-date": {
            recent: ["openai/gpt-5", "openai/gpt-4.1"],
            updatedAt: "+275760-09-13T00:00:00.000Z",
          },
        },
      }),
    );

    const plans = await Promise.resolve(
      detectDiscordLegacyStateMigrations({
        cfg: {},
        env: {},
        oauthDir: path.join(stateDir, "credentials"),
        stateDir,
      }),
    );

    const plan = plans?.[0];
    if (plan?.kind !== "plugin-state-import") {
      throw new Error("expected plugin-state import plan");
    }
    const entries = await plan.readEntries();
    expect(
      entries.map((entry) => {
        const value = entry.value as { updatedAt?: unknown };
        return value.updatedAt;
      }),
    ).toEqual(["+275760-09-13T00:00:00.000Z", "+275760-09-12T23:59:59.999Z"]);
  });

  it("keeps legacy JSON import order near max Date", async () => {
    const stateDir = stateWorkspace.dir;
    const sourcePath = path.join(stateDir, "discord", "model-picker-preferences.json");
    await fs.mkdir(path.dirname(sourcePath), { recursive: true });
    await fs.writeFile(
      sourcePath,
      JSON.stringify({
        version: 1,
        entries: {
          "discord:default:dm:user:near-max-date": {
            recent: ["openai/gpt-5", "openai/gpt-4.1"],
            updatedAt: "+275760-09-12T23:59:59.999Z",
          },
        },
      }),
    );

    const plans = await Promise.resolve(
      detectDiscordLegacyStateMigrations({
        cfg: {},
        env: {},
        oauthDir: path.join(stateDir, "credentials"),
        stateDir,
      }),
    );

    const plan = plans?.[0];
    if (plan?.kind !== "plugin-state-import") {
      throw new Error("expected plugin-state import plan");
    }
    const entries = await plan.readEntries();
    expect(
      entries.map((entry) => {
        const value = entry.value as { modelRef?: unknown };
        return value.modelRef;
      }),
    ).toEqual(["openai/gpt-5", "openai/gpt-4.1"]);
    expect(
      entries.map((entry) => {
        const value = entry.value as { updatedAt?: unknown };
        return value.updatedAt;
      }),
    ).toEqual(["+275760-09-13T00:00:00.000Z", "+275760-09-12T23:59:59.999Z"]);
  });

  it("plans legacy thread bindings JSON import into plugin state", async () => {
    const stateDir = stateWorkspace.dir;
    const sourcePath = path.join(stateDir, "discord", "thread-bindings.json");
    await fs.mkdir(path.dirname(sourcePath), { recursive: true });
    const boundAt = Date.now() - 10_000;
    const expiresAt = boundAt + 60_000;
    await fs.writeFile(
      sourcePath,
      JSON.stringify({
        version: 1,
        bindings: {
          "legacy-thread": {
            accountId: "default",
            channelId: "parent-1",
            threadId: "legacy-thread",
            targetKind: "subagent",
            targetSessionKey: "agent:main:subagent:legacy",
            agentId: "main",
            boundBy: "system",
            boundAt,
            expiresAt,
          },
        },
      }),
    );

    const plans = await Promise.resolve(
      detectDiscordLegacyStateMigrations({
        cfg: {},
        env: {},
        oauthDir: path.join(stateDir, "credentials"),
        stateDir,
      }),
    );

    expect(plans).toHaveLength(1);
    const plan = plans?.[0];
    expect(plan?.kind).toBe("plugin-state-import");
    if (plan?.kind !== "plugin-state-import") {
      throw new Error("expected plugin-state import plan");
    }
    expect(plan.pluginId).toBe("discord");
    expect(plan.namespace).toBe("thread-bindings");
    const entries = await plan.readEntries();
    expect(entries).toStrictEqual([
      {
        key: "default:legacy-thread",
        value: {
          accountId: "default",
          channelId: "parent-1",
          threadId: "legacy-thread",
          targetKind: "subagent",
          targetSessionKey: "agent:main:subagent:legacy",
          agentId: "main",
          boundBy: "system",
          boundAt,
          lastActivityAt: boundAt,
          idleTimeoutMs: 0,
          maxAgeMs: expiresAt - boundAt,
        },
      },
    ]);
  });

  it("detects model picker and thread binding legacy JSON together", async () => {
    const stateDir = stateWorkspace.dir;
    const discordDir = path.join(stateDir, "discord");
    await fs.mkdir(discordDir, { recursive: true });
    await fs.writeFile(path.join(discordDir, "command-deploy-cache.json"), "{}");
    await fs.writeFile(
      path.join(discordDir, "model-picker-preferences.json"),
      JSON.stringify({ version: 1, entries: {} }),
    );
    await fs.writeFile(
      path.join(discordDir, "thread-bindings.json"),
      JSON.stringify({ version: 1, bindings: {} }),
    );

    const plans = await Promise.resolve(
      detectDiscordLegacyStateMigrations({
        cfg: {},
        env: {},
        oauthDir: path.join(stateDir, "credentials"),
        stateDir,
      }),
    );
    if (!plans) {
      throw new Error("expected migration plans");
    }

    expect(
      plans?.map((plan) => ({
        kind: plan.kind,
        label: plan.label,
        sourcePath: plan.sourcePath,
        targetPath: plan.targetPath,
        namespace: plan.kind === "plugin-state-import" ? plan.namespace : null,
      })),
    ).toEqual([
      {
        kind: "plugin-state-import",
        label: "Discord command deployment cache",
        sourcePath: path.join(discordDir, "command-deploy-cache.json"),
        targetPath: "plugin state:command-deploy-hashes",
        namespace: "command-deploy-hashes",
      },
      {
        kind: "plugin-state-import",
        label: "Discord model picker preferences",
        sourcePath: path.join(discordDir, "model-picker-preferences.json"),
        targetPath: "plugin state:model-picker-preferences",
        namespace: "model-picker-preferences",
      },
      {
        kind: "plugin-state-import",
        label: "Discord thread bindings",
        sourcePath: path.join(discordDir, "thread-bindings.json"),
        targetPath: "plugin state:thread-bindings",
        namespace: "thread-bindings",
      },
    ]);
    await expect(
      stateMigrations[0]?.detectLegacyState({
        config: {},
        env: {},
        stateDir,
        oauthDir: path.join(stateDir, "credentials"),
        context: { openPluginStateKeyedStore: () => ({}) } as never,
      }),
    ).resolves.toEqual({ preview: plans.map(buildLegacyMigrationPreview) });
  });

  it("archives valid empty legacy thread bindings after an empty import", async () => {
    const stateDir = stateWorkspace.dir;
    const sourcePath = path.join(stateDir, "discord", "thread-bindings.json");
    await fs.mkdir(path.dirname(sourcePath), { recursive: true });
    await fs.writeFile(sourcePath, JSON.stringify({ version: 1, bindings: {} }));

    const plans = await Promise.resolve(
      detectDiscordLegacyStateMigrations({
        cfg: {},
        env: {},
        oauthDir: path.join(stateDir, "credentials"),
        stateDir,
      }),
    );

    const plan = plans?.[0];
    if (plan?.kind !== "plugin-state-import") {
      throw new Error("expected plugin-state import plan");
    }
    expect(plan.cleanupWhenEmpty).toBe(true);
    expect(plan.readEntries()).toEqual([]);
  });

  it("keeps malformed legacy thread bindings for doctor warning", async () => {
    const stateDir = stateWorkspace.dir;
    const sourcePath = path.join(stateDir, "discord", "thread-bindings.json");
    await fs.mkdir(path.dirname(sourcePath), { recursive: true });
    await fs.writeFile(sourcePath, JSON.stringify({ version: 2, bindings: {} }));

    const plans = await Promise.resolve(
      detectDiscordLegacyStateMigrations({
        cfg: {},
        env: {},
        oauthDir: path.join(stateDir, "credentials"),
        stateDir,
      }),
    );

    const plan = plans?.[0];
    if (plan?.kind !== "plugin-state-import") {
      throw new Error("expected plugin-state import plan");
    }
    expect(() => plan.readEntries()).toThrow(
      "legacy Discord thread bindings store must have version 1 bindings",
    );
  });
});
