import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";

/**
 * Test for issue #6070:
 * `openclaw config set/unset` must update snapshot.resolved (user config after $include/${ENV},
 * but before runtime defaults), so runtime defaults don't leak into the written config.
 */

const mockReadConfigFileSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>();
const mockWriteConfigFile = vi.fn<
  (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise<void>
>(async () => {});

vi.mock("../config/config.js", () => ({
  readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
  writeConfigFile: (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) =>
    mockWriteConfigFile(cfg, options),
}));

const mockLog = vi.fn();
const mockError = vi.fn();
const mockExit = vi.fn((code: number) => {
  const errorMessages = mockError.mock.calls.map((c) => c.join(" ")).join("; ");
  throw new Error(`__exit__:${code} - ${errorMessages}`);
});

vi.mock("../runtime.js", () => ({
  defaultRuntime: {
    log: (...args: unknown[]) => mockLog(...args),
    error: (...args: unknown[]) => mockError(...args),
    exit: (code: number) => mockExit(code),
  },
}));

function buildSnapshot(params: {
  resolved: OpenClawConfig;
  config: OpenClawConfig;
}): ConfigFileSnapshot {
  return {
    path: "/tmp/openclaw.json",
    exists: true,
    raw: JSON.stringify(params.resolved),
    parsed: params.resolved,
    resolved: params.resolved,
    valid: true,
    config: params.config,
    issues: [],
    warnings: [],
    legacyIssues: [],
  };
}

function setSnapshot(resolved: OpenClawConfig, config: OpenClawConfig) {
  mockReadConfigFileSnapshot.mockResolvedValueOnce(buildSnapshot({ resolved, config }));
}

function setSnapshotOnce(snapshot: ConfigFileSnapshot) {
  mockReadConfigFileSnapshot.mockResolvedValueOnce(snapshot);
}

function withRuntimeDefaults(resolved: OpenClawConfig): OpenClawConfig {
  return {
    ...resolved,
    agents: {
      ...resolved.agents,
      defaults: {
        model: "gpt-5.2",
      } as never,
    } as never,
  };
}

function makeInvalidSnapshot(params: {
  issues: ConfigFileSnapshot["issues"];
  path?: string;
}): ConfigFileSnapshot {
  return {
    path: params.path ?? "/tmp/custom-openclaw.json",
    exists: true,
    raw: "{}",
    parsed: {},
    resolved: {},
    valid: false,
    config: {},
    issues: params.issues,
    warnings: [],
    legacyIssues: [],
  };
}

async function runValidateJsonAndGetPayload() {
  await expect(runConfigCommand(["config", "validate", "--json"])).rejects.toThrow("__exit__:1");
  const raw = mockLog.mock.calls.at(0)?.[0];
  expect(typeof raw).toBe("string");
  return JSON.parse(String(raw)) as {
    valid: boolean;
    path: string;
    issues: Array<{
      path: string;
      message: string;
      allowedValues?: string[];
      allowedValuesHiddenCount?: number;
    }>;
  };
}

let registerConfigCli: typeof import("./config-cli.js").registerConfigCli;
let sharedProgram: Command;

async function runConfigCommand(args: string[]) {
  await sharedProgram.parseAsync(args, { from: "user" });
}

describe("config cli", () => {
  beforeAll(async () => {
    ({ registerConfigCli } = await import("./config-cli.js"));
    sharedProgram = new Command();
    sharedProgram.exitOverride();
    registerConfigCli(sharedProgram);
  });

  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe("config set - issue #6070", () => {
    it("preserves existing config keys when setting a new value", async () => {
      const resolved: OpenClawConfig = {
        agents: {
          list: [{ id: "main" }, { id: "oracle", workspace: "~/oracle-workspace" }],
        },
        gateway: { port: 18789 },
        tools: { allow: ["group:fs"] },
        logging: { level: "debug" },
      };
      const runtimeMerged: OpenClawConfig = {
        ...withRuntimeDefaults(resolved),
      };
      setSnapshot(resolved, runtimeMerged);

      await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]);

      expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
      const written = mockWriteConfigFile.mock.calls[0]?.[0];
      expect(written.gateway?.auth).toEqual({ mode: "token" });
      expect(written.gateway?.port).toBe(18789);
      expect(written.agents).toEqual(resolved.agents);
      expect(written.tools).toEqual(resolved.tools);
      expect(written.logging).toEqual(resolved.logging);
      expect(written.agents).not.toHaveProperty("defaults");
    });

    it("does not inject runtime defaults into the written config", async () => {
      const resolved: OpenClawConfig = {
        gateway: { port: 18789 },
      };
      const runtimeMerged = {
        ...resolved,
        agents: {
          defaults: {
            model: "gpt-5.2",
            contextWindow: 128_000,
            maxTokens: 16_000,
          },
        } as never,
        messages: { ackReaction: "✅" } as never,
        sessions: { persistence: { enabled: true } } as never,
      } as unknown as OpenClawConfig;
      setSnapshot(resolved, runtimeMerged);

      await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]);

      expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
      const written = mockWriteConfigFile.mock.calls[0]?.[0];
      expect(written).not.toHaveProperty("agents.defaults.model");
      expect(written).not.toHaveProperty("agents.defaults.contextWindow");
      expect(written).not.toHaveProperty("agents.defaults.maxTokens");
      expect(written).not.toHaveProperty("messages.ackReaction");
      expect(written).not.toHaveProperty("sessions.persistence");
      expect(written.gateway?.port).toBe(18789);
      expect(written.gateway?.auth).toEqual({ mode: "token" });
    });

    it("auto-seeds a valid Ollama provider when setting only models.providers.ollama.apiKey", async () => {
      const resolved: OpenClawConfig = {
        gateway: { port: 18789 },
      };
      setSnapshot(resolved, resolved);

      await runConfigCommand(["config", "set", "models.providers.ollama.apiKey", '"ollama-local"']);

      expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
      const written = mockWriteConfigFile.mock.calls[0]?.[0];
      expect(written.models?.providers?.ollama).toEqual({
        baseUrl: "http://127.0.0.1:11434",
        api: "ollama",
        models: [],
        apiKey: "ollama-local",
      });
    });
  });

  describe("config get", () => {
    it("redacts sensitive values", async () => {
      const resolved: OpenClawConfig = {
        gateway: {
          auth: {
            token: "super-secret-token",
          },
        },
      };
      setSnapshot(resolved, resolved);

      await runConfigCommand(["config", "get", "gateway.auth.token"]);

      expect(mockLog).toHaveBeenCalledWith("__OPENCLAW_REDACTED__");
    });
  });

  describe("config validate", () => {
    it("prints success and exits 0 when config is valid", async () => {
      const resolved: OpenClawConfig = {
        gateway: { port: 18789 },
      };
      setSnapshot(resolved, resolved);

      await runConfigCommand(["config", "validate"]);

      expect(mockExit).not.toHaveBeenCalled();
      expect(mockError).not.toHaveBeenCalled();
      expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Config valid:"));
    });

    it("prints issues and exits 1 when config is invalid", async () => {
      setSnapshotOnce(
        makeInvalidSnapshot({
          issues: [
            {
              path: "agents.defaults.suppressToolErrorWarnings",
              message: "Unrecognized key(s) in object",
            },
          ],
        }),
      );

      await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");

      expect(mockError).toHaveBeenCalledWith(expect.stringContaining("Config invalid at"));
      expect(mockError).toHaveBeenCalledWith(
        expect.stringContaining("agents.defaults.suppressToolErrorWarnings"),
      );
      expect(mockLog).not.toHaveBeenCalled();
    });

    it("returns machine-readable JSON with --json for invalid config", async () => {
      setSnapshotOnce(
        makeInvalidSnapshot({
          issues: [{ path: "gateway.bind", message: "Invalid enum value" }],
        }),
      );

      const payload = await runValidateJsonAndGetPayload();
      expect(payload.valid).toBe(false);
      expect(payload.path).toBe("/tmp/custom-openclaw.json");
      expect(payload.issues).toEqual([{ path: "gateway.bind", message: "Invalid enum value" }]);
      expect(mockError).not.toHaveBeenCalled();
    });

    it("preserves allowed-values metadata in --json output", async () => {
      setSnapshotOnce(
        makeInvalidSnapshot({
          issues: [
            {
              path: "update.channel",
              message: 'Invalid input (allowed: "stable", "beta", "dev")',
              allowedValues: ["stable", "beta", "dev"],
              allowedValuesHiddenCount: 0,
            },
          ],
        }),
      );

      const payload = await runValidateJsonAndGetPayload();
      expect(payload.valid).toBe(false);
      expect(payload.path).toBe("/tmp/custom-openclaw.json");
      expect(payload.issues).toEqual([
        {
          path: "update.channel",
          message: 'Invalid input (allowed: "stable", "beta", "dev")',
          allowedValues: ["stable", "beta", "dev"],
        },
      ]);
      expect(mockError).not.toHaveBeenCalled();
    });

    it("prints file-not-found and exits 1 when config file is missing", async () => {
      setSnapshotOnce({
        path: "/tmp/openclaw.json",
        exists: false,
        raw: null,
        parsed: {},
        resolved: {},
        valid: true,
        config: {},
        issues: [],
        warnings: [],
        legacyIssues: [],
      });

      await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
      expect(mockError).toHaveBeenCalledWith(expect.stringContaining("Config file not found:"));
      expect(mockLog).not.toHaveBeenCalled();
    });
  });

  describe("config set parsing flags", () => {
    it("falls back to raw string when parsing fails and strict mode is off", async () => {
      const resolved: OpenClawConfig = { gateway: { port: 18789 } };
      setSnapshot(resolved, resolved);

      await runConfigCommand(["config", "set", "gateway.auth.mode", "{bad"]);

      expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
      const written = mockWriteConfigFile.mock.calls[0]?.[0];
      expect(written.gateway?.auth).toEqual({ mode: "{bad" });
    });

    it("throws when strict parsing is enabled via --strict-json", async () => {
      await expect(
        runConfigCommand(["config", "set", "gateway.auth.mode", "{bad", "--strict-json"]),
      ).rejects.toThrow("__exit__:1");

      expect(mockWriteConfigFile).not.toHaveBeenCalled();
      expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
    });

    it("keeps --json as a strict parsing alias", async () => {
      await expect(
        runConfigCommand(["config", "set", "gateway.auth.mode", "{bad", "--json"]),
      ).rejects.toThrow("__exit__:1");

      expect(mockWriteConfigFile).not.toHaveBeenCalled();
      expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
    });

    it("shows --strict-json and keeps --json as a legacy alias in help", async () => {
      const program = new Command();
      registerConfigCli(program);

      const configCommand = program.commands.find((command) => command.name() === "config");
      const setCommand = configCommand?.commands.find((command) => command.name() === "set");
      const helpText = setCommand?.helpInformation() ?? "";

      expect(helpText).toContain("--strict-json");
      expect(helpText).toContain("--json");
      expect(helpText).toContain("Legacy alias for --strict-json");
    });
  });

  describe("path hardening", () => {
    it("rejects blocked prototype-key segments for config get", async () => {
      await expect(runConfigCommand(["config", "get", "gateway.__proto__.token"])).rejects.toThrow(
        "Invalid path segment: __proto__",
      );

      expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
      expect(mockWriteConfigFile).not.toHaveBeenCalled();
    });

    it("rejects blocked prototype-key segments for config set", async () => {
      await expect(
        runConfigCommand(["config", "set", "tools.constructor.profile", '"sandbox"']),
      ).rejects.toThrow("Invalid path segment: constructor");

      expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
      expect(mockWriteConfigFile).not.toHaveBeenCalled();
    });

    it("rejects blocked prototype-key segments for config unset", async () => {
      await expect(
        runConfigCommand(["config", "unset", "channels.prototype.enabled"]),
      ).rejects.toThrow("Invalid path segment: prototype");

      expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
      expect(mockWriteConfigFile).not.toHaveBeenCalled();
    });
  });

  describe("config unset - issue #6070", () => {
    it("preserves existing config keys when unsetting a value", async () => {
      const resolved: OpenClawConfig = {
        agents: { list: [{ id: "main" }] },
        gateway: { port: 18789 },
        tools: {
          profile: "coding",
          alsoAllow: ["agents_list"],
        },
        logging: { level: "debug" },
      };
      const runtimeMerged: OpenClawConfig = {
        ...withRuntimeDefaults(resolved),
      };
      setSnapshot(resolved, runtimeMerged);

      await runConfigCommand(["config", "unset", "tools.alsoAllow"]);

      expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
      const written = mockWriteConfigFile.mock.calls[0]?.[0];
      expect(written.tools).not.toHaveProperty("alsoAllow");
      expect(written.agents).not.toHaveProperty("defaults");
      expect(written.agents?.list).toEqual(resolved.agents?.list);
      expect(written.gateway).toEqual(resolved.gateway);
      expect(written.tools?.profile).toBe("coding");
      expect(written.logging).toEqual(resolved.logging);
      expect(mockWriteConfigFile.mock.calls[0]?.[1]).toEqual({
        unsetPaths: [["tools", "alsoAllow"]],
      });
    });
  });

  describe("config file", () => {
    it("prints the active config file path", async () => {
      const resolved: OpenClawConfig = { gateway: { port: 18789 } };
      setSnapshot(resolved, resolved);

      await runConfigCommand(["config", "file"]);

      expect(mockLog).toHaveBeenCalledWith("/tmp/openclaw.json");
      expect(mockWriteConfigFile).not.toHaveBeenCalled();
    });

    it("handles config file path with home directory", async () => {
      const resolved: OpenClawConfig = { gateway: { port: 18789 } };
      const snapshot = buildSnapshot({ resolved, config: resolved });
      snapshot.path = "/home/user/.openclaw/openclaw.json";
      mockReadConfigFileSnapshot.mockResolvedValueOnce(snapshot);

      await runConfigCommand(["config", "file"]);

      expect(mockLog).toHaveBeenCalledWith("/home/user/.openclaw/openclaw.json");
    });
  });
});
