import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { createConfigIO } from "./io.js";
import type { OpenClawConfig } from "./types.js";

describe("config io write", () => {
  let fixtureRoot = "";
  let homeCaseId = 0;
  const silentLogger = {
    warn: () => {},
    error: () => {},
  };

  async function withSuiteHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
    const home = path.join(fixtureRoot, `case-${homeCaseId++}`);
    await fs.mkdir(home, { recursive: true });
    return fn(home);
  }

  beforeAll(async () => {
    fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-io-"));
  });

  afterAll(async () => {
    await fs.rm(fixtureRoot, { recursive: true, force: true });
  });

  async function writeConfigAndCreateIo(params: {
    home: string;
    initialConfig: Record<string, unknown>;
    env?: NodeJS.ProcessEnv;
    logger?: { warn: (msg: string) => void; error: (msg: string) => void };
  }) {
    const configPath = path.join(params.home, ".openclaw", "openclaw.json");
    await fs.mkdir(path.dirname(configPath), { recursive: true });
    await fs.writeFile(configPath, JSON.stringify(params.initialConfig, null, 2), "utf-8");

    const io = createConfigIO({
      env: params.env ?? {},
      homedir: () => params.home,
      logger: params.logger ?? silentLogger,
    });
    const snapshot = await io.readConfigFileSnapshot();
    expect(snapshot.valid).toBe(true);
    return { configPath, io, snapshot };
  }

  async function writeTokenAuthAndReadConfig(params: {
    io: { writeConfigFile: (config: Record<string, unknown>) => Promise<void> };
    snapshot: { config: Record<string, unknown> };
    configPath: string;
  }) {
    const next = structuredClone(params.snapshot.config);
    const gateway =
      next.gateway && typeof next.gateway === "object"
        ? (next.gateway as Record<string, unknown>)
        : {};
    next.gateway = {
      ...gateway,
      auth: { mode: "token" },
    };
    await params.io.writeConfigFile(next);
    return JSON.parse(await fs.readFile(params.configPath, "utf-8")) as Record<string, unknown>;
  }

  async function writeGatewayPatchAndReadLastAuditEntry(params: {
    home: string;
    initialConfig: Record<string, unknown>;
    gatewayPatch: Record<string, unknown>;
    env?: NodeJS.ProcessEnv;
  }) {
    const { io, snapshot, configPath } = await writeConfigAndCreateIo({
      home: params.home,
      initialConfig: params.initialConfig,
      env: params.env,
      logger: {
        warn: vi.fn(),
        error: vi.fn(),
      },
    });
    const auditPath = path.join(params.home, ".openclaw", "logs", "config-audit.jsonl");
    const next = structuredClone(snapshot.config);
    const gateway =
      next.gateway && typeof next.gateway === "object"
        ? (next.gateway as Record<string, unknown>)
        : {};
    next.gateway = {
      ...gateway,
      ...params.gatewayPatch,
    };
    await io.writeConfigFile(next);
    const lines = (await fs.readFile(auditPath, "utf-8")).trim().split("\n").filter(Boolean);
    const last = JSON.parse(lines.at(-1) ?? "{}") as Record<string, unknown>;
    return { last, lines, configPath };
  }

  const createGatewayCommandsInput = (): Record<string, unknown> => ({
    gateway: { mode: "local" },
    commands: { ownerDisplay: "hash" },
  });

  const expectInputOwnerDisplayUnchanged = (input: Record<string, unknown>) => {
    expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
  };

  const readPersistedCommands = async (configPath: string) => {
    const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
      commands?: Record<string, unknown>;
    };
    return persisted.commands;
  };

  async function runUnsetNoopCase(params: { home: string; unsetPaths: string[][] }) {
    const { configPath, io } = await writeConfigAndCreateIo({
      home: params.home,
      initialConfig: createGatewayCommandsInput(),
    });

    const input = createGatewayCommandsInput();
    await io.writeConfigFile(input, { unsetPaths: params.unsetPaths });

    expectInputOwnerDisplayUnchanged(input);
    expect((await readPersistedCommands(configPath))?.ownerDisplay).toBe("hash");
  }

  it("persists caller changes onto resolved config without leaking runtime defaults", async () => {
    await withSuiteHome(async (home) => {
      const { configPath, io, snapshot } = await writeConfigAndCreateIo({
        home,
        initialConfig: { gateway: { port: 18789 } },
      });
      const persisted = await writeTokenAuthAndReadConfig({ io, snapshot, configPath });
      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.runIf(process.platform !== "win32")(
    "tightens world-writable state dir when writing the default config",
    async () => {
      await withSuiteHome(async (home) => {
        const stateDir = path.join(home, ".openclaw");
        await fs.mkdir(stateDir, { recursive: true, mode: 0o777 });
        await fs.chmod(stateDir, 0o777);

        const io = createConfigIO({
          env: {} as NodeJS.ProcessEnv,
          homedir: () => home,
          logger: silentLogger,
        });

        await io.writeConfigFile({ gateway: { mode: "local" } });

        const stat = await fs.stat(stateDir);
        expect(stat.mode & 0o777).toBe(0o700);
      });
    },
  );

  it('shows actionable guidance for dmPolicy="open" without wildcard allowFrom', async () => {
    await withSuiteHome(async (home) => {
      const io = createConfigIO({
        env: {} as NodeJS.ProcessEnv,
        homedir: () => home,
        logger: silentLogger,
      });

      const invalidConfig: OpenClawConfig = {
        channels: {
          telegram: {
            dmPolicy: "open",
            allowFrom: [],
          },
        },
      } satisfies OpenClawConfig;

      await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow(
        "openclaw config set channels.telegram.allowFrom '[\"*\"]'",
      );
      await expect(io.writeConfigFile(invalidConfig)).rejects.toThrow(
        'openclaw config set channels.telegram.dmPolicy "pairing"',
      );
    });
  });

  it("honors explicit unset paths when schema defaults would otherwise reappear", async () => {
    await withSuiteHome(async (home) => {
      const { configPath, io, snapshot } = await writeConfigAndCreateIo({
        home,
        initialConfig: {
          gateway: { auth: { mode: "none" } },
          commands: { ownerDisplay: "hash" },
        },
      });

      const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
      if (
        next.commands &&
        typeof next.commands === "object" &&
        "ownerDisplay" in (next.commands as Record<string, unknown>)
      ) {
        delete (next.commands as Record<string, unknown>).ownerDisplay;
      }

      await io.writeConfigFile(next, { unsetPaths: [["commands", "ownerDisplay"]] });

      const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
        commands?: Record<string, unknown>;
      };
      expect(persisted.commands ?? {}).not.toHaveProperty("ownerDisplay");
    });
  });

  it("does not mutate caller config when unsetPaths is applied on first write", async () => {
    await withSuiteHome(async (home) => {
      const configPath = path.join(home, ".openclaw", "openclaw.json");
      const io = createConfigIO({
        env: {} as NodeJS.ProcessEnv,
        homedir: () => home,
        logger: silentLogger,
      });

      const input: Record<string, unknown> = {
        gateway: { mode: "local" },
        commands: { ownerDisplay: "hash" },
      };

      await io.writeConfigFile(input, { unsetPaths: [["commands", "ownerDisplay"]] });

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

  it("does not mutate caller config when unsetPaths is applied on existing files", async () => {
    await withSuiteHome(async (home) => {
      const { configPath, io, snapshot } = await writeConfigAndCreateIo({
        home,
        initialConfig: {
          gateway: { mode: "local" },
          commands: { ownerDisplay: "hash" },
        },
      });

      const input = structuredClone(snapshot.config) as Record<string, unknown>;
      await io.writeConfigFile(input, { unsetPaths: [["commands", "ownerDisplay"]] });

      expectInputOwnerDisplayUnchanged(input);
      expect((await readPersistedCommands(configPath)) ?? {}).not.toHaveProperty("ownerDisplay");
    });
  });

  it("keeps caller arrays immutable when unsetting array entries", async () => {
    await withSuiteHome(async (home) => {
      const { configPath, io, snapshot } = await writeConfigAndCreateIo({
        home,
        initialConfig: {
          gateway: { mode: "local" },
          tools: { alsoAllow: ["exec", "fetch", "read"] },
        },
      });

      const input = structuredClone(snapshot.config) as Record<string, unknown>;
      await io.writeConfigFile(input, { unsetPaths: [["tools", "alsoAllow", "1"]] });

      expect((input.tools as { alsoAllow: string[] }).alsoAllow).toEqual(["exec", "fetch", "read"]);
      const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
        tools?: { alsoAllow?: string[] };
      };
      expect(persisted.tools?.alsoAllow).toEqual(["exec", "read"]);
    });
  });

  it("treats missing unset paths as no-op without mutating caller config", async () => {
    await withSuiteHome(async (home) => {
      await runUnsetNoopCase({
        home,
        unsetPaths: [["commands", "missingKey"]],
      });
    });
  });

  it("ignores blocked prototype-key unset path segments", async () => {
    await withSuiteHome(async (home) => {
      await runUnsetNoopCase({
        home,
        unsetPaths: [
          ["commands", "__proto__"],
          ["commands", "constructor"],
          ["commands", "prototype"],
        ],
      });
    });
  });

  it("preserves env var references when writing", async () => {
    await withSuiteHome(async (home) => {
      const { configPath, io, snapshot } = await writeConfigAndCreateIo({
        home,
        env: { OPENAI_API_KEY: "sk-secret" } as NodeJS.ProcessEnv,
        initialConfig: {
          agents: {
            defaults: {
              cliBackends: {
                codex: {
                  command: "codex",
                  env: {
                    OPENAI_API_KEY: "${OPENAI_API_KEY}",
                  },
                },
              },
            },
          },
          gateway: { port: 18789 },
        },
      });
      const persisted = (await writeTokenAuthAndReadConfig({ io, snapshot, configPath })) as {
        agents: { defaults: { cliBackends: { codex: { env: { OPENAI_API_KEY: string } } } } };
        gateway: { port: number; auth: { mode: string } };
      };
      expect(persisted.agents.defaults.cliBackends.codex.env.OPENAI_API_KEY).toBe(
        "${OPENAI_API_KEY}",
      );
      expect(persisted.gateway).toEqual({
        port: 18789,
        auth: { mode: "token" },
      });
    });
  });

  it("does not reintroduce Slack/Discord legacy dm.policy defaults when writing", async () => {
    await withSuiteHome(async (home) => {
      const { configPath, io, snapshot } = await writeConfigAndCreateIo({
        home,
        initialConfig: {
          channels: {
            discord: {
              dmPolicy: "pairing",
              dm: { enabled: true, policy: "pairing" },
            },
            slack: {
              dmPolicy: "pairing",
              dm: { enabled: true, policy: "pairing" },
            },
          },
          gateway: { port: 18789 },
        },
      });

      const next = structuredClone(snapshot.config);
      // Simulate doctor removing legacy keys while keeping dm enabled.
      if (next.channels?.discord?.dm && typeof next.channels.discord.dm === "object") {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test helper
        delete (next.channels.discord.dm as any).policy;
      }
      if (next.channels?.slack?.dm && typeof next.channels.slack.dm === "object") {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test helper
        delete (next.channels.slack.dm as any).policy;
      }

      await io.writeConfigFile(next);

      const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) 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("keeps env refs in arrays when appending entries", async () => {
    await withSuiteHome(async (home) => {
      const configPath = path.join(home, ".openclaw", "openclaw.json");
      await fs.mkdir(path.dirname(configPath), { recursive: true });
      await fs.writeFile(
        configPath,
        JSON.stringify(
          {
            agents: {
              defaults: {
                cliBackends: {
                  codex: {
                    command: "codex",
                    args: ["${DISCORD_USER_ID}", "123"],
                  },
                },
              },
            },
          },
          null,
          2,
        ),
        "utf-8",
      );

      const io = createConfigIO({
        env: { DISCORD_USER_ID: "999" } as NodeJS.ProcessEnv,
        homedir: () => home,
        logger: silentLogger,
      });

      const snapshot = await io.readConfigFileSnapshot();
      expect(snapshot.valid).toBe(true);

      const next = structuredClone(snapshot.config);
      const codexBackend = next.agents?.defaults?.cliBackends?.codex;
      const args = Array.isArray(codexBackend?.args) ? codexBackend?.args : [];
      next.agents = {
        ...next.agents,
        defaults: {
          ...next.agents?.defaults,
          cliBackends: {
            ...next.agents?.defaults?.cliBackends,
            codex: {
              ...codexBackend,
              command: typeof codexBackend?.command === "string" ? codexBackend.command : "codex",
              args: [...args, "456"],
            },
          },
        },
      };

      await io.writeConfigFile(next);

      const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
        agents: {
          defaults: {
            cliBackends: {
              codex: {
                args: string[];
              };
            };
          };
        };
      };
      expect(persisted.agents.defaults.cliBackends.codex.args).toEqual([
        "${DISCORD_USER_ID}",
        "123",
        "456",
      ]);
    });
  });

  it("logs an overwrite audit entry when replacing an existing config file", async () => {
    await withSuiteHome(async (home) => {
      const warn = vi.fn();
      const { configPath, io, snapshot } = await writeConfigAndCreateIo({
        home,
        initialConfig: { gateway: { port: 18789 } },
        env: {} as NodeJS.ProcessEnv,
        logger: {
          warn: warn as (msg: string) => void,
          error: vi.fn() as (msg: string) => void,
        },
      });
      const next = structuredClone(snapshot.config);
      next.gateway = {
        ...next.gateway,
        auth: { mode: "token" },
      };

      await io.writeConfigFile(next);

      const overwriteLog = warn.mock.calls
        .map((call) => call[0])
        .find((entry) => typeof entry === "string" && entry.startsWith("Config overwrite:"));
      expect(typeof overwriteLog).toBe("string");
      expect(overwriteLog).toContain(configPath);
      expect(overwriteLog).toContain(`${configPath}.bak`);
      expect(overwriteLog).toContain("sha256");
    });
  });

  it("does not log an overwrite audit entry when creating config for the first time", async () => {
    await withSuiteHome(async (home) => {
      const warn = vi.fn();
      const io = createConfigIO({
        env: {} as NodeJS.ProcessEnv,
        homedir: () => home,
        logger: {
          warn,
          error: vi.fn(),
        },
      });

      await io.writeConfigFile({
        gateway: { mode: "local" },
      });

      const overwriteLogs = warn.mock.calls.filter(
        (call) => typeof call[0] === "string" && call[0].startsWith("Config overwrite:"),
      );
      expect(overwriteLogs).toHaveLength(0);
    });
  });

  it("appends config write audit JSONL entries with forensic metadata", async () => {
    await withSuiteHome(async (home) => {
      const { configPath, lines, last } = await writeGatewayPatchAndReadLastAuditEntry({
        home,
        initialConfig: { gateway: { port: 18789 } },
        gatewayPatch: { mode: "local" },
        env: {} as NodeJS.ProcessEnv,
      });
      expect(lines.length).toBeGreaterThan(0);
      expect(last.source).toBe("config-io");
      expect(last.event).toBe("config.write");
      expect(last.configPath).toBe(configPath);
      expect(last.existsBefore).toBe(true);
      expect(last.hasMetaAfter).toBe(true);
      expect(last.previousHash).toBeTypeOf("string");
      expect(last.nextHash).toBeTypeOf("string");
      expect(last.result === "rename" || last.result === "copy-fallback").toBe(true);
    });
  });

  it("records gateway watch session markers in config audit entries", async () => {
    await withSuiteHome(async (home) => {
      const { last } = await writeGatewayPatchAndReadLastAuditEntry({
        home,
        initialConfig: { gateway: { mode: "local" } },
        gatewayPatch: { bind: "loopback" },
        env: {
          OPENCLAW_WATCH_MODE: "1",
          OPENCLAW_WATCH_SESSION: "watch-session-1",
          OPENCLAW_WATCH_COMMAND: "gateway --force",
        } as NodeJS.ProcessEnv,
      });
      expect(last.watchMode).toBe(true);
      expect(last.watchSession).toBe("watch-session-1");
      expect(last.watchCommand).toBe("gateway --force");
    });
  });
});
