import { describe, expect, it } from "vitest";
import {
  collectChangedPaths,
  formatConfigValidationFailure,
  restoreEnvRefsFromMap,
  resolvePersistCandidateForWrite,
  resolveWriteEnvSnapshotForPath,
  unsetPathForWrite,
} from "./io.write-prepare.js";
import type { OpenClawConfig } from "./types.js";

describe("config io write prepare", () => {
  it("persists caller changes onto resolved config without leaking runtime defaults", () => {
    const persisted = resolvePersistCandidateForWrite({
      runtimeConfig: {
        gateway: { port: 18789 },
        agents: { defaults: { cliBackend: "codex" } },
        messages: { ackReaction: "eyes" },
        sessions: { persistence: true },
      },
      sourceConfig: {
        gateway: { port: 18789 },
      },
      nextConfig: {
        gateway: {
          port: 18789,
          auth: { mode: "token" },
        },
      },
    }) as Record<string, unknown>;

    expect(persisted.gateway).toEqual({
      port: 18789,
      auth: { mode: "token" },
    });
    expect(persisted).not.toHaveProperty("agents.defaults");
    expect(persisted).not.toHaveProperty("messages.ackReaction");
    expect(persisted).not.toHaveProperty("sessions.persistence");
  });

  it('formats actionable guidance for dmPolicy="open" without wildcard allowFrom', () => {
    const message = formatConfigValidationFailure(
      "channels.telegram.allowFrom",
      'channels.telegram.dmPolicy = "open" requires channels.telegram.allowFrom to include "*"',
    );

    expect(message).toContain("openclaw config set channels.telegram.allowFrom '[\"*\"]'");
    expect(message).toContain('openclaw config set channels.telegram.dmPolicy "pairing"');
  });

  it("unsets explicit paths when runtime defaults would otherwise reappear", () => {
    const next = unsetPathForWrite(
      {
        gateway: { auth: { mode: "none" } },
        commands: { ownerDisplay: "hash" },
      },
      ["commands", "ownerDisplay"],
    );

    expect(next.changed).toBe(true);
    expect(next.next.commands ?? {}).not.toHaveProperty("ownerDisplay");
  });

  it("does not mutate caller config when unsetting existing config objects", () => {
    const input: OpenClawConfig = {
      gateway: { mode: "local" },
      commands: { ownerDisplay: "hash" },
    } satisfies OpenClawConfig;

    const next = unsetPathForWrite(input, ["commands", "ownerDisplay"]);

    expect(input).toEqual({
      gateway: { mode: "local" },
      commands: { ownerDisplay: "hash" },
    });
    expect(next.next.commands ?? {}).not.toHaveProperty("ownerDisplay");
  });

  it("keeps caller arrays immutable when unsetting array entries", () => {
    const input: OpenClawConfig = {
      gateway: { mode: "local" },
      tools: { alsoAllow: ["exec", "fetch", "read"] },
    } satisfies OpenClawConfig;

    const next = unsetPathForWrite(input, ["tools", "alsoAllow", "1"]);

    expect(input.tools!.alsoAllow).toEqual(["exec", "fetch", "read"]);
    expect((next.next.tools as { alsoAllow?: string[] } | undefined)?.alsoAllow).toEqual([
      "exec",
      "read",
    ]);
  });

  it("treats missing unset paths as no-op without mutating caller config", () => {
    const input: OpenClawConfig = {
      gateway: { mode: "local" },
      commands: { ownerDisplay: "hash" },
    } satisfies OpenClawConfig;

    const next = unsetPathForWrite(input, ["commands", "missingKey"]);

    expect(next.changed).toBe(false);
    expect(next.next).toBe(input);
    expect(input).toEqual({
      gateway: { mode: "local" },
      commands: { ownerDisplay: "hash" },
    });
  });

  it("ignores blocked prototype-key unset path segments", () => {
    const input: OpenClawConfig = {
      gateway: { mode: "local" },
      commands: { ownerDisplay: "hash" },
    } satisfies OpenClawConfig;

    const blocked = [
      ["commands", "__proto__"],
      ["commands", "constructor"],
      ["commands", "prototype"],
    ].map((segments) => unsetPathForWrite(input, segments));

    for (const result of blocked) {
      expect(result.changed).toBe(false);
      expect(result.next).toBe(input);
    }
    expect(input).toEqual({
      gateway: { mode: "local" },
      commands: { ownerDisplay: "hash" },
    });
  });

  it("preserves env refs on unchanged paths while keeping changed paths resolved", () => {
    const changedPaths = new Set<string>();
    collectChangedPaths(
      {
        agents: {
          defaults: {
            cliBackends: {
              codex: {
                env: { OPENAI_API_KEY: "sk-secret" },
              },
            },
          },
        },
        gateway: { port: 18789 },
      },
      {
        agents: {
          defaults: {
            cliBackends: {
              codex: {
                env: { OPENAI_API_KEY: "sk-secret" },
              },
            },
          },
        },
        gateway: {
          port: 18789,
          auth: { mode: "token" },
        },
      },
      "",
      changedPaths,
    );

    const restored = restoreEnvRefsFromMap(
      {
        agents: {
          defaults: {
            cliBackends: {
              codex: {
                env: { OPENAI_API_KEY: "sk-secret" },
              },
            },
          },
        },
        gateway: {
          port: 18789,
          auth: { mode: "token" },
        },
      },
      "",
      new Map([["agents.defaults.cliBackends.codex.env.OPENAI_API_KEY", "${OPENAI_API_KEY}"]]),
      changedPaths,
    ) as {
      agents: { defaults: { cliBackends: { codex: { env: { OPENAI_API_KEY: string } } } } };
      gateway: { port: number; auth: { mode: string } };
    };

    expect(restored.agents.defaults.cliBackends.codex.env.OPENAI_API_KEY).toBe("${OPENAI_API_KEY}");
    expect(restored.gateway).toEqual({
      port: 18789,
      auth: { mode: "token" },
    });
  });

  it("preserves env refs in arrays while keeping appended entries resolved", () => {
    const changedPaths = new Set<string>();
    collectChangedPaths(
      {
        agents: {
          defaults: {
            cliBackends: {
              codex: {
                args: ["${DISCORD_USER_ID}", "123"],
              },
            },
          },
        },
      },
      {
        agents: {
          defaults: {
            cliBackends: {
              codex: {
                args: ["${DISCORD_USER_ID}", "123", "456"],
              },
            },
          },
        },
      },
      "",
      changedPaths,
    );

    const restored = restoreEnvRefsFromMap(
      {
        agents: {
          defaults: {
            cliBackends: {
              codex: {
                args: ["999", "123", "456"],
              },
            },
          },
        },
      },
      "",
      new Map([["agents.defaults.cliBackends.codex.args[0]", "${DISCORD_USER_ID}"]]),
      changedPaths,
    ) as {
      agents: { defaults: { cliBackends: { codex: { args: string[] } } } };
    };

    expect(restored.agents.defaults.cliBackends.codex.args).toEqual([
      "${DISCORD_USER_ID}",
      "123",
      "456",
    ]);
  });

  it("keeps the read-time env snapshot when writing the same config path", () => {
    const snapshot = { OPENAI_API_KEY: "sk-secret" };
    expect(
      resolveWriteEnvSnapshotForPath({
        actualConfigPath: "/tmp/openclaw.json",
        expectedConfigPath: "/tmp/openclaw.json",
        envSnapshotForRestore: snapshot,
      }),
    ).toBe(snapshot);
  });

  it("drops the read-time env snapshot when writing a different config path", () => {
    expect(
      resolveWriteEnvSnapshotForPath({
        actualConfigPath: "/tmp/openclaw.json",
        expectedConfigPath: "/tmp/other.json",
        envSnapshotForRestore: { OPENAI_API_KEY: "sk-secret" },
      }),
    ).toBeUndefined();
  });

  it("keeps plugin AJV defaults out of the persisted candidate", () => {
    const sourceConfig = {
      gateway: { port: 18789 },
      channels: {
        bluebubbles: {
          serverUrl: "http://localhost:1234",
          password: "test-password",
        },
      },
    } satisfies OpenClawConfig;

    const runtimeConfig: OpenClawConfig = {
      gateway: { port: 18789 },
      channels: {
        bluebubbles: {
          serverUrl: "http://localhost:1234",
          password: "test-password",
          enrichGroupParticipantsFromContacts: true,
        },
      },
    } satisfies OpenClawConfig;

    const nextConfig: OpenClawConfig = structuredClone(runtimeConfig);
    nextConfig.gateway = {
      ...nextConfig.gateway,
      auth: { mode: "token" },
    };

    const persisted = resolvePersistCandidateForWrite({
      runtimeConfig,
      sourceConfig,
      nextConfig,
    }) as Record<string, unknown>;

    expect(persisted.gateway).toEqual({
      port: 18789,
      auth: { mode: "token" },
    });
    const channels = persisted.channels as Record<string, Record<string, unknown>> | undefined;
    expect(channels?.bluebubbles).toBeDefined();
    expect(channels?.bluebubbles).not.toHaveProperty("enrichGroupParticipantsFromContacts");
    expect(channels?.bluebubbles?.serverUrl).toBe("http://localhost:1234");
    expect(channels?.bluebubbles?.password).toBe("test-password");
  });

  it("does not reintroduce legacy nested dm.policy defaults in the persisted candidate", () => {
    const sourceConfig: OpenClawConfig = {
      channels: {
        discord: {
          dmPolicy: "pairing",
          dm: { enabled: true, policy: "pairing" },
        },
        slack: {
          dmPolicy: "pairing",
          dm: { enabled: true, policy: "pairing" },
        },
      },
      gateway: { port: 18789 },
    } satisfies OpenClawConfig;

    const nextConfig = structuredClone(sourceConfig);
    delete (nextConfig.channels?.discord?.dm as { enabled?: boolean; policy?: string } | undefined)
      ?.policy;
    delete (nextConfig.channels?.slack?.dm as { enabled?: boolean; policy?: string } | undefined)
      ?.policy;

    const persisted = resolvePersistCandidateForWrite({
      runtimeConfig: sourceConfig,
      sourceConfig,
      nextConfig,
    }) as {
      channels?: {
        discord?: { dm?: Record<string, unknown>; dmPolicy?: unknown };
        slack?: { dm?: Record<string, unknown>; dmPolicy?: unknown };
      };
    };

    expect(persisted.channels?.discord?.dmPolicy).toBe("pairing");
    expect(persisted.channels?.discord?.dm).toEqual({ enabled: true });
    expect(persisted.channels?.slack?.dmPolicy).toBe("pairing");
    expect(persisted.channels?.slack?.dm).toEqual({ enabled: true });
  });

  it("preserves normalized nested channel enabled keys during unrelated writes", () => {
    const sourceConfig = {
      channels: {
        slack: {
          channels: {
            ops: {
              enabled: false,
            },
          },
        },
        googlechat: {
          groups: {
            "spaces/aaa": {
              enabled: true,
            },
          },
        },
        discord: {
          guilds: {
            "100": {
              channels: {
                general: {
                  enabled: false,
                },
              },
            },
          },
        },
      },
    } satisfies OpenClawConfig;

    const nextConfig: OpenClawConfig = {
      ...structuredClone(sourceConfig),
      gateway: {
        auth: { mode: "token" },
      },
    };

    const persisted = resolvePersistCandidateForWrite({
      runtimeConfig: sourceConfig,
      sourceConfig,
      nextConfig,
    }) as {
      channels?: {
        slack?: { channels?: Record<string, Record<string, unknown>> };
        googlechat?: { groups?: Record<string, Record<string, unknown>> };
        discord?: {
          guilds?: Record<string, { channels?: Record<string, Record<string, unknown>> }>;
        };
      };
      gateway?: Record<string, unknown>;
    };

    expect(persisted.gateway).toEqual({
      auth: { mode: "token" },
    });
    expect(persisted.channels?.slack?.channels?.ops).toEqual({ enabled: false });
    expect(persisted.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({ enabled: true });
    expect(persisted.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
      enabled: false,
    });
  });
});
