import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
  CircularIncludeError,
  ConfigIncludeError,
  MAX_INCLUDE_FILE_BYTES,
  deepMerge,
  type IncludeResolver,
  resolveConfigIncludes,
} from "./includes.js";

const ROOT_DIR = path.parse(process.cwd()).root;
const CONFIG_DIR = path.join(ROOT_DIR, "config");
const ETC_OPENCLAW_DIR = path.join(ROOT_DIR, "etc", "openclaw");
const SHARED_DIR = path.join(ROOT_DIR, "shared");

const DEFAULT_BASE_PATH = path.join(CONFIG_DIR, "openclaw.json");

function configPath(...parts: string[]) {
  return path.join(CONFIG_DIR, ...parts);
}

function etcOpenClawPath(...parts: string[]) {
  return path.join(ETC_OPENCLAW_DIR, ...parts);
}

function sharedPath(...parts: string[]) {
  return path.join(SHARED_DIR, ...parts);
}

function createMockResolver(files: Record<string, unknown>): IncludeResolver {
  return {
    readFile: (filePath: string) => {
      if (filePath in files) {
        return JSON.stringify(files[filePath]);
      }
      throw new Error(`ENOENT: no such file: ${filePath}`);
    },
    parseJson: JSON.parse,
  };
}

function resolve(obj: unknown, files: Record<string, unknown> = {}, basePath = DEFAULT_BASE_PATH) {
  return resolveConfigIncludes(obj, basePath, createMockResolver(files));
}

function expectResolveIncludeError(
  run: () => unknown,
  expectedPattern?: RegExp,
): ConfigIncludeError {
  let thrown: unknown;
  try {
    run();
  } catch (error) {
    thrown = error;
  }
  expect(thrown).toBeInstanceOf(ConfigIncludeError);
  if (expectedPattern) {
    expect((thrown as Error).message).toMatch(expectedPattern);
  }
  return thrown as ConfigIncludeError;
}

describe("resolveConfigIncludes", () => {
  it.each([
    { name: "string", value: "hello", expected: "hello" },
    { name: "number", value: 42, expected: 42 },
    { name: "boolean", value: true, expected: true },
    { name: "null", value: null, expected: null },
    { name: "array", value: [1, 2, { a: 1 }], expected: [1, 2, { a: 1 }] },
    {
      name: "nested object",
      value: { foo: "bar", nested: { x: 1 } },
      expected: { foo: "bar", nested: { x: 1 } },
    },
  ] as const)("passes through non-include $name values unchanged", ({ value, expected }) => {
    expect(resolve(value)).toEqual(expected);
  });

  it("rejects absolute path outside config directory (CWE-22)", () => {
    const absolute = etcOpenClawPath("agents.json");
    const files = { [absolute]: { list: [{ id: "main" }] } };
    const obj = { agents: { $include: absolute } };
    expectResolveIncludeError(() => resolve(obj, files), /escapes config directory/);
  });

  it.each([
    {
      name: "single file include",
      files: { [configPath("agents.json")]: { list: [{ id: "main" }] } },
      obj: { agents: { $include: "./agents.json" } },
      expected: {
        agents: { list: [{ id: "main" }] },
      },
    },
    {
      name: "array include deep merge",
      files: {
        [configPath("a.json")]: { "group-a": ["agent1"] },
        [configPath("b.json")]: { "group-b": ["agent2"] },
      },
      obj: { broadcast: { $include: ["./a.json", "./b.json"] } },
      expected: {
        broadcast: {
          "group-a": ["agent1"],
          "group-b": ["agent2"],
        },
      },
    },
    {
      name: "array include overlapping keys",
      files: {
        [configPath("a.json")]: { agents: { defaults: { workspace: "~/a" } } },
        [configPath("b.json")]: { agents: { list: [{ id: "main" }] } },
      },
      obj: { $include: ["./a.json", "./b.json"] },
      expected: {
        agents: {
          defaults: { workspace: "~/a" },
          list: [{ id: "main" }],
        },
      },
    },
  ] as const)("resolves include merges: $name", ({ obj, files, expected }) => {
    expect(resolve(obj, files)).toEqual(expected);
  });

  it.each([
    {
      name: "adds sibling keys after include",
      obj: { $include: "./base.json", c: 3 },
      expected: { a: 1, b: 2, c: 3 },
    },
    {
      name: "lets siblings override included keys",
      obj: { $include: "./base.json", b: 99 },
      expected: { a: 1, b: 99 },
    },
  ] as const)("merges include content with sibling keys: $name", ({ obj, expected }) => {
    const files = { [configPath("base.json")]: { a: 1, b: 2 } };
    expect(resolve(obj, files)).toEqual(expected);
  });

  it.each([
    { includeFile: "list.json", included: ["a", "b"] },
    { includeFile: "value.json", included: "hello" },
  ] as const)(
    "throws when sibling keys are used with non-object include $includeFile",
    ({ includeFile, included }) => {
      const files = { [configPath(includeFile)]: included };
      const obj = { $include: `./${includeFile}`, extra: true };
      expectResolveIncludeError(
        () => resolve(obj, files),
        /Sibling keys require included content to be an object/,
      );
    },
  );

  it("resolves nested includes", () => {
    const files = {
      [configPath("level1.json")]: { nested: { $include: "./level2.json" } },
      [configPath("level2.json")]: { deep: "value" },
    };
    const obj = { $include: "./level1.json" };
    expect(resolve(obj, files)).toEqual({
      nested: { deep: "value" },
    });
  });

  it.each([
    {
      name: "read failures",
      run: () => resolve({ $include: "./missing.json" }),
      pattern: /Failed to read include file/,
    },
    {
      name: "parse failures",
      run: () =>
        resolveConfigIncludes({ $include: "./bad.json" }, DEFAULT_BASE_PATH, {
          readFile: () => "{ invalid json }",
          parseJson: JSON.parse,
        }),
      pattern: /Failed to parse include file/,
    },
  ] as const)("surfaces include $name", ({ run, pattern }) => {
    expectResolveIncludeError(run, pattern);
  });

  it("throws CircularIncludeError for circular includes", () => {
    const aPath = configPath("a.json");
    const bPath = configPath("b.json");
    const resolver: IncludeResolver = {
      readFile: (filePath: string) => {
        if (filePath === aPath) {
          return JSON.stringify({ $include: "./b.json" });
        }
        if (filePath === bPath) {
          return JSON.stringify({ $include: "./a.json" });
        }
        throw new Error(`Unknown file: ${filePath}`);
      },
      parseJson: JSON.parse,
    };
    const obj = { $include: "./a.json" };
    try {
      resolveConfigIncludes(obj, DEFAULT_BASE_PATH, resolver);
      throw new Error("expected circular include error");
    } catch (err) {
      expect(err).toBeInstanceOf(CircularIncludeError);
      const circular = err as CircularIncludeError;
      expect(circular.chain).toEqual(expect.arrayContaining([DEFAULT_BASE_PATH, aPath, bPath]));
      expect(circular.message).toMatch(/Circular include detected/);
      expect(circular.message).toContain("a.json");
      expect(circular.message).toContain("b.json");
    }
  });

  it.each([
    {
      name: "rejects scalar include value",
      obj: { $include: 123 },
      expectedPattern: /expected string or array/,
    },
    {
      name: "rejects number in include array",
      obj: { $include: ["./valid.json", 123] },
      expectedPattern: /expected string, got number/,
    },
    {
      name: "rejects null in include array",
      obj: { $include: ["./valid.json", null] },
      expectedPattern: /expected string, got object/,
    },
    {
      name: "rejects boolean in include array",
      obj: { $include: ["./valid.json", false] },
      expectedPattern: /expected string, got boolean/,
    },
  ] as const)("throws on invalid include value/item types: $name", ({ obj, expectedPattern }) => {
    const files = { [configPath("valid.json")]: { valid: true } };
    expectResolveIncludeError(() => resolve(obj, files), expectedPattern);
  });

  it("respects max depth limit", () => {
    const files: Record<string, unknown> = {};
    for (let i = 0; i < 15; i++) {
      files[configPath(`level${i}.json`)] = {
        $include: `./level${i + 1}.json`,
      };
    }
    files[configPath("level15.json")] = { done: true };

    const obj = { $include: "./level0.json" };
    expectResolveIncludeError(() => resolve(obj, files), /Maximum include depth/);
  });

  it("allows depth 10 but rejects depth 11", () => {
    const okFiles: Record<string, unknown> = {};
    for (let i = 0; i < 9; i++) {
      okFiles[configPath(`ok${i}.json`)] = { $include: `./ok${i + 1}.json` };
    }
    okFiles[configPath("ok9.json")] = { done: true };
    expect(resolve({ $include: "./ok0.json" }, okFiles)).toEqual({
      done: true,
    });

    const failFiles: Record<string, unknown> = {};
    for (let i = 0; i < 10; i++) {
      failFiles[configPath(`fail${i}.json`)] = {
        $include: `./fail${i + 1}.json`,
      };
    }
    failFiles[configPath("fail10.json")] = { done: true };
    expectResolveIncludeError(
      () => resolve({ $include: "./fail0.json" }, failFiles),
      /Maximum include depth/,
    );
  });

  it.each([
    {
      name: "resolves nested relative file path",
      files: {
        [configPath("clients", "mueller", "agents.json")]: { id: "mueller" },
      },
      obj: { agent: { $include: "./clients/mueller/agents.json" } },
      expected: {
        agent: { id: "mueller" },
      },
    },
    {
      name: "preserves nested override ordering",
      files: {
        [configPath("base.json")]: { nested: { $include: "./nested.json" } },
        [configPath("nested.json")]: { a: 1, b: 2 },
      },
      obj: { $include: "./base.json", nested: { b: 9 } },
      expected: {
        nested: { a: 1, b: 9 },
      },
    },
  ] as const)(
    "handles relative paths and nested include ordering: $name",
    ({ obj, files, expected }) => {
      expect(resolve(obj, files)).toEqual(expected);
    },
  );

  it("enforces traversal boundaries while allowing safe nested-parent paths", () => {
    expectResolveIncludeError(
      () =>
        resolve(
          { $include: "../../shared/common.json" },
          { [sharedPath("common.json")]: { shared: true } },
          configPath("sub", "openclaw.json"),
        ),
      /escapes config directory/,
    );

    expect(
      resolve(
        { $include: "./sub/child.json" },
        {
          [configPath("sub", "child.json")]: { $include: "../shared/common.json" },
          [configPath("shared", "common.json")]: { shared: true },
        },
      ),
    ).toEqual({
      shared: true,
    });
  });
});

describe("real-world config patterns", () => {
  it.each([
    {
      name: "per-client agent includes",
      files: {
        [configPath("clients", "mueller.json")]: {
          agents: [
            {
              id: "mueller-screenshot",
              workspace: "~/clients/mueller/screenshot",
            },
            {
              id: "mueller-transcribe",
              workspace: "~/clients/mueller/transcribe",
            },
          ],
          broadcast: {
            "group-mueller": ["mueller-screenshot", "mueller-transcribe"],
          },
        },
        [configPath("clients", "schmidt.json")]: {
          agents: [
            {
              id: "schmidt-screenshot",
              workspace: "~/clients/schmidt/screenshot",
            },
          ],
          broadcast: { "group-schmidt": ["schmidt-screenshot"] },
        },
      },
      obj: {
        gateway: { port: 18789 },
        $include: ["./clients/mueller.json", "./clients/schmidt.json"],
      },
      expected: {
        gateway: { port: 18789 },
        agents: [
          { id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" },
          { id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" },
          { id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" },
        ],
        broadcast: {
          "group-mueller": ["mueller-screenshot", "mueller-transcribe"],
          "group-schmidt": ["schmidt-screenshot"],
        },
      },
    },
    {
      name: "modular config structure",
      files: {
        [configPath("gateway.json")]: {
          gateway: { port: 18789, bind: "loopback" },
        },
        [configPath("channels", "whatsapp.json")]: {
          channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] } },
        },
        [configPath("agents", "defaults.json")]: {
          agents: { defaults: { sandbox: { mode: "all" } } },
        },
      },
      obj: {
        $include: ["./gateway.json", "./channels/whatsapp.json", "./agents/defaults.json"],
      },
      expected: {
        gateway: { port: 18789, bind: "loopback" },
        channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+49123"] } },
        agents: { defaults: { sandbox: { mode: "all" } } },
      },
    },
  ] as const)("supports common modular include layouts: $name", ({ obj, files, expected }) => {
    expect(resolve(obj, files)).toEqual(expected);
  });
});
describe("security: path traversal protection (CWE-22)", () => {
  function expectRejectedTraversalPaths(
    cases: ReadonlyArray<{ includePath: string; expectEscapesMessage: boolean }>,
  ) {
    for (const { includePath, expectEscapesMessage } of cases) {
      const obj = { $include: includePath };
      expect(() => resolve(obj, {}), includePath).toThrow(ConfigIncludeError);
      if (expectEscapesMessage) {
        expect(() => resolve(obj, {}), includePath).toThrow(/escapes config directory/);
      }
    }
  }

  describe("absolute path attacks", () => {
    it("rejects absolute path attack variants", () => {
      const cases = [
        { includePath: "/etc/passwd", expectEscapesMessage: true },
        { includePath: "/etc/shadow", expectEscapesMessage: true },
        { includePath: `${process.env.HOME}/.ssh/id_rsa`, expectEscapesMessage: false },
        { includePath: "/tmp/malicious.json", expectEscapesMessage: false },
        { includePath: "/", expectEscapesMessage: false },
      ] as const;
      expectRejectedTraversalPaths(cases);
    });
  });

  describe("relative traversal attacks", () => {
    it("rejects relative traversal path variants", () => {
      const cases = [
        { includePath: "../../etc/passwd", expectEscapesMessage: true },
        { includePath: "../../../etc/shadow", expectEscapesMessage: false },
        { includePath: "../../../../../../../../etc/passwd", expectEscapesMessage: false },
        { includePath: "../sibling-dir/secret.json", expectEscapesMessage: false },
        { includePath: "/config/../../../etc/passwd", expectEscapesMessage: false },
      ] as const;
      expectRejectedTraversalPaths(cases);
    });
  });

  describe("legitimate includes (should work)", () => {
    it.each([
      {
        name: "same-directory with ./ prefix",
        includePath: "./sub.json",
        files: { [configPath("sub.json")]: { key: "value" } },
        expected: { key: "value" },
      },
      {
        name: "same-directory without ./ prefix",
        includePath: "sub.json",
        files: { [configPath("sub.json")]: { key: "value" } },
        expected: { key: "value" },
      },
      {
        name: "subdirectory",
        includePath: "./sub/nested.json",
        files: { [configPath("sub", "nested.json")]: { nested: true } },
        expected: { nested: true },
      },
      {
        name: "deep subdirectory",
        includePath: "./a/b/c/deep.json",
        files: { [configPath("a", "b", "c", "deep.json")]: { deep: true } },
        expected: { deep: true },
      },
    ] as const)(
      "allows legitimate include path under config root: $name",
      ({ includePath, files, expected }) => {
        const obj = { $include: includePath };
        expect(resolve(obj, files)).toEqual(expected);
      },
    );

    // Note: Upward traversal from nested configs is restricted for security.
    // Each config file can only include files from its own directory and subdirectories.
    // This prevents potential path traversal attacks even in complex nested scenarios.
  });

  describe("error properties", () => {
    it.each([
      {
        includePath: "/etc/passwd",
        expectedMessageIncludes: ["escapes config directory", "/etc/passwd"],
      },
      {
        includePath: "/etc/shadow",
        expectedMessageIncludes: ["/etc/shadow"],
      },
      {
        includePath: "../../etc/passwd",
        expectedMessageIncludes: ["escapes config directory", "../../etc/passwd"],
      },
    ] as const)(
      "preserves error type/path/message details for $includePath",
      ({ includePath, expectedMessageIncludes }) => {
        const obj = { $include: includePath };
        try {
          resolve(obj, {});
          expect.fail("Should have thrown");
        } catch (err) {
          expect(err, includePath).toBeInstanceOf(ConfigIncludeError);
          expect(err, includePath).toHaveProperty("name", "ConfigIncludeError");
          expect((err as ConfigIncludeError).includePath, includePath).toBe(includePath);
          for (const messagePart of expectedMessageIncludes) {
            expect((err as Error).message, `${includePath}: ${messagePart}`).toContain(messagePart);
          }
        }
      },
    );
  });

  describe("array includes with malicious paths", () => {
    it.each([
      {
        name: "one malicious path",
        files: { [configPath("good.json")]: { good: true } },
        includePaths: ["./good.json", "/etc/passwd"],
      },
      {
        name: "multiple malicious paths",
        files: {},
        includePaths: ["/etc/passwd", "/etc/shadow"],
      },
    ] as const)("rejects arrays with malicious include paths: $name", ({ includePaths, files }) => {
      const obj = { $include: includePaths };
      expect(() => resolve(obj, files)).toThrow(ConfigIncludeError);
    });

    it("allows array with all legitimate paths", () => {
      const files = {
        [configPath("a.json")]: { a: 1 },
        [configPath("b.json")]: { b: 2 },
      };
      const obj = { $include: ["./a.json", "./b.json"] };
      expect(resolve(obj, files)).toEqual({ a: 1, b: 2 });
    });
  });

  describe("prototype pollution protection", () => {
    it("blocks prototype pollution vectors in shallow and nested merges", () => {
      const cases = [
        {
          base: {},
          incoming: JSON.parse('{"__proto__":{"polluted":true}}'),
          expected: {},
        },
        {
          base: { safe: 1 },
          incoming: { prototype: { x: 1 }, constructor: { y: 2 }, normal: 3 },
          expected: { safe: 1, normal: 3 },
        },
        {
          base: { nested: { a: 1 } },
          incoming: { nested: JSON.parse('{"__proto__":{"polluted":true}}') },
          expected: { nested: { a: 1 } },
        },
      ] as const;

      for (const { base, incoming, expected } of cases) {
        const result = deepMerge(base, incoming);
        expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
        expect(result).toEqual(expected);
      }
    });
  });

  describe("edge cases", () => {
    it.each([
      { includePath: "./file\x00.json", expectedError: undefined },
      { includePath: "//etc/passwd", expectedError: ConfigIncludeError },
    ] as const)("rejects malformed include path $includePath", ({ includePath, expectedError }) => {
      const obj = { $include: includePath };
      if (expectedError) {
        expectResolveIncludeError(() => resolve(obj, {}));
        return;
      }
      // Path with null byte should be rejected or handled safely.
      expect(() => resolve(obj, {}), includePath).toThrow();
    });

    it("allows child include when config is at filesystem root", () => {
      const rootConfigPath = path.join(path.parse(process.cwd()).root, "test.json");
      const childPath = path.join(path.parse(process.cwd()).root, "child.json");
      const files = { [childPath]: { root: true } };
      const obj = { $include: childPath };
      expect(resolve(obj, files, rootConfigPath)).toEqual({ root: true });
    });

    it("allows include files when the config root path is a symlink", async () => {
      await withTempDir({ prefix: "openclaw-includes-symlink-" }, async (tempRoot) => {
        const realRoot = path.join(tempRoot, "real");
        const linkRoot = path.join(tempRoot, "link");
        await fs.mkdir(path.join(realRoot, "includes"), { recursive: true });
        await fs.writeFile(
          path.join(realRoot, "includes", "extra.json5"),
          "{ logging: { redactSensitive: 'tools' } }\n",
          "utf-8",
        );
        await fs.symlink(realRoot, linkRoot, process.platform === "win32" ? "junction" : undefined);

        const result = resolveConfigIncludes(
          { $include: "./includes/extra.json5" },
          path.join(linkRoot, "openclaw.json"),
        );
        expect(result).toEqual({ logging: { redactSensitive: "tools" } });
      });
    });

    it("rejects include files that are hardlinked aliases", async () => {
      if (process.platform === "win32") {
        return;
      }
      await withTempDir({ prefix: "openclaw-includes-hardlink-" }, async (tempRoot) => {
        const configDir = path.join(tempRoot, "config");
        const outsideDir = path.join(tempRoot, "outside");
        await fs.mkdir(configDir, { recursive: true });
        await fs.mkdir(outsideDir, { recursive: true });
        const includePath = path.join(configDir, "extra.json5");
        const outsidePath = path.join(outsideDir, "secret.json5");
        await fs.writeFile(outsidePath, '{"logging":{"redactSensitive":"tools"}}\n', "utf-8");
        try {
          await fs.link(outsidePath, includePath);
        } catch (err) {
          if ((err as NodeJS.ErrnoException).code === "EXDEV") {
            return;
          }
          throw err;
        }

        expect(() =>
          resolveConfigIncludes(
            { $include: "./extra.json5" },
            path.join(configDir, "openclaw.json"),
          ),
        ).toThrow(/security checks|hardlink/i);
      });
    });

    it("rejects oversized include files", async () => {
      await withTempDir({ prefix: "openclaw-includes-big-" }, async (tempRoot) => {
        const configDir = path.join(tempRoot, "config");
        await fs.mkdir(configDir, { recursive: true });
        const includePath = path.join(configDir, "big.json5");
        const payload = "a".repeat(MAX_INCLUDE_FILE_BYTES + 1);
        await fs.writeFile(includePath, `{"blob":"${payload}"}`, "utf-8");

        expect(() =>
          resolveConfigIncludes({ $include: "./big.json5" }, path.join(configDir, "openclaw.json")),
        ).toThrow(/security checks|max/i);
      });
    });
  });
});
