import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";

type TmpDirOptions = NonNullable<Parameters<typeof resolvePreferredOpenClawTmpDir>[0]>;

function fallbackTmp(uid = 501) {
  return path.join("/var/fallback", `openclaw-${uid}`);
}

function nodeErrorWithCode(code: string) {
  const err = new Error(code) as Error & { code?: string };
  err.code = code;
  return err;
}

function secureDirStat(uid = 501) {
  return {
    isDirectory: () => true,
    isSymbolicLink: () => false,
    uid,
    mode: 0o40700,
  };
}

function makeDirStat(params?: {
  isDirectory?: boolean;
  isSymbolicLink?: boolean;
  uid?: number;
  mode?: number;
}) {
  return {
    isDirectory: () => params?.isDirectory ?? true,
    isSymbolicLink: () => params?.isSymbolicLink ?? false,
    uid: params?.uid ?? 501,
    mode: params?.mode ?? 0o40700,
  };
}

function readOnlyTmpAccessSync() {
  return vi.fn((target: string) => {
    if (target === "/tmp") {
      throw new Error("read-only");
    }
  });
}

function resolveWithReadOnlyTmpFallback(params: {
  fallbackPath: string;
  fallbackLstatSync: NonNullable<TmpDirOptions["lstatSync"]>;
  chmodSync?: NonNullable<TmpDirOptions["chmodSync"]>;
  warn?: NonNullable<TmpDirOptions["warn"]>;
}) {
  return resolvePreferredOpenClawTmpDir({
    accessSync: readOnlyTmpAccessSync(),
    lstatSync: vi.fn((target: string) => {
      if (target === POSIX_OPENCLAW_TMP_DIR) {
        throw nodeErrorWithCode("ENOENT");
      }
      if (target === params.fallbackPath) {
        return params.fallbackLstatSync(target);
      }
      return secureDirStat(501);
    }),
    mkdirSync: vi.fn(),
    chmodSync: params.chmodSync,
    getuid: vi.fn(() => 501),
    tmpdir: vi.fn(() => "/var/fallback"),
    warn: params.warn,
  });
}

function symlinkTmpDirLstat() {
  return vi.fn(() => makeDirStat({ isSymbolicLink: true, mode: 0o120777 }));
}

function expectFallsBackToOsTmpDir(params: { lstatSync: NonNullable<TmpDirOptions["lstatSync"]> }) {
  const { resolved, tmpdir } = resolveWithMocks({ lstatSync: params.lstatSync });
  expect(resolved).toBe(fallbackTmp());
  expect(tmpdir).toHaveBeenCalled();
}

function expectResolvesFallbackTmpDir(params: {
  lstatSync: NonNullable<TmpDirOptions["lstatSync"]>;
  accessSync?: NonNullable<TmpDirOptions["accessSync"]>;
}) {
  const { resolved, tmpdir } = resolveWithMocks({
    lstatSync: params.lstatSync,
    ...(params.accessSync ? { accessSync: params.accessSync } : {}),
  });
  expect(resolved).toBe(fallbackTmp());
  expect(tmpdir).toHaveBeenCalled();
}

function missingThenSecureLstat(uid = 501) {
  return vi
    .fn<NonNullable<TmpDirOptions["lstatSync"]>>()
    .mockImplementationOnce(() => {
      throw nodeErrorWithCode("ENOENT");
    })
    .mockImplementationOnce(() => secureDirStat(uid));
}

function resolveWithMocks(params: {
  lstatSync: NonNullable<TmpDirOptions["lstatSync"]>;
  fallbackLstatSync?: NonNullable<TmpDirOptions["lstatSync"]>;
  accessSync?: NonNullable<TmpDirOptions["accessSync"]>;
  chmodSync?: NonNullable<TmpDirOptions["chmodSync"]>;
  warn?: NonNullable<TmpDirOptions["warn"]>;
  uid?: number;
  tmpdirPath?: string;
}) {
  const uid = params.uid ?? 501;
  const fallbackPath = fallbackTmp(uid);
  const accessSync = params.accessSync ?? vi.fn();
  const chmodSync = params.chmodSync ?? vi.fn();
  const warn = params.warn ?? vi.fn();
  const wrappedLstatSync = vi.fn((target: string) => {
    if (target === POSIX_OPENCLAW_TMP_DIR) {
      return params.lstatSync(target);
    }
    if (target === fallbackPath) {
      if (params.fallbackLstatSync) {
        return params.fallbackLstatSync(target);
      }
      return secureDirStat(uid);
    }
    return secureDirStat(uid);
  }) as NonNullable<TmpDirOptions["lstatSync"]>;
  const mkdirSync = vi.fn();
  const getuid = vi.fn(() => uid);
  const tmpdir = vi.fn(() => params.tmpdirPath ?? "/var/fallback");
  const resolved = resolvePreferredOpenClawTmpDir({
    accessSync,
    chmodSync,
    lstatSync: wrappedLstatSync,
    mkdirSync,
    getuid,
    tmpdir,
    warn,
  });
  return { resolved, accessSync, lstatSync: wrappedLstatSync, mkdirSync, tmpdir };
}

describe("resolvePreferredOpenClawTmpDir", () => {
  it("prefers /tmp/openclaw when it already exists and is writable", () => {
    const lstatSync: NonNullable<TmpDirOptions["lstatSync"]> = vi.fn(() => ({
      isDirectory: () => true,
      isSymbolicLink: () => false,
      uid: 501,
      mode: 0o40700,
    }));
    const { resolved, accessSync, tmpdir } = resolveWithMocks({ lstatSync });

    expect(lstatSync).toHaveBeenCalledTimes(1);
    expect(accessSync).toHaveBeenCalledTimes(1);
    expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR);
    expect(tmpdir).not.toHaveBeenCalled();
  });

  it("prefers /tmp/openclaw when it does not exist but /tmp is writable", () => {
    const lstatSyncMock = missingThenSecureLstat();

    const { resolved, accessSync, mkdirSync, tmpdir } = resolveWithMocks({
      lstatSync: lstatSyncMock,
    });

    expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR);
    expect(accessSync).toHaveBeenCalledWith("/tmp", expect.any(Number));
    expect(mkdirSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, expect.any(Object));
    expect(tmpdir).not.toHaveBeenCalled();
  });

  it.each([
    {
      name: "falls back to os.tmpdir()/openclaw when /tmp/openclaw is not a directory",
      lstatSync: vi.fn(() => makeDirStat({ isDirectory: false, mode: 0o100644 })),
    },
    {
      name: "falls back to os.tmpdir()/openclaw when /tmp is not writable",
      lstatSync: vi.fn(() => {
        throw nodeErrorWithCode("ENOENT");
      }),
      accessSync: vi.fn((target: string) => {
        if (target === "/tmp") {
          throw new Error("read-only");
        }
      }),
    },
    {
      name: "falls back when /tmp/openclaw exists but is not writable",
      lstatSync: vi.fn(() => secureDirStat()),
      accessSync: vi.fn((target: string) => {
        if (target === POSIX_OPENCLAW_TMP_DIR) {
          throw new Error("not writable");
        }
      }),
    },
    {
      name: "falls back when /tmp/openclaw is a symlink",
      lstatSync: symlinkTmpDirLstat(),
    },
    {
      name: "falls back when /tmp/openclaw is not owned by the current user",
      lstatSync: vi.fn(() => makeDirStat({ uid: 0 })),
    },
    {
      name: "falls back when /tmp/openclaw is group/other writable",
      lstatSync: vi.fn(() => makeDirStat({ mode: 0o40777 })),
    },
  ])("$name", ({ lstatSync, accessSync }) => {
    if (accessSync) {
      expectResolvesFallbackTmpDir({ lstatSync, accessSync });
      return;
    }
    expectFallsBackToOsTmpDir({ lstatSync });
  });

  it("repairs existing /tmp/openclaw permissions when they are too broad", () => {
    let preferredMode = 0o40777;
    const chmodSync = vi.fn((target: string, mode: number) => {
      if (target === POSIX_OPENCLAW_TMP_DIR && mode === 0o700) {
        preferredMode = 0o40700;
      }
    });
    const warn = vi.fn();

    const { resolved, tmpdir } = resolveWithMocks({
      lstatSync: vi.fn(() => makeDirStat({ mode: preferredMode })),
      chmodSync,
      warn,
    });

    expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR);
    expect(chmodSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, 0o700);
    expect(warn).toHaveBeenCalledWith(expect.stringContaining("tightened permissions on temp dir"));
    expect(tmpdir).not.toHaveBeenCalled();
  });

  it("repairs /tmp/openclaw after create when the initial mode stays too broad", () => {
    let preferredMode = 0o40775;
    let chmodCalls = 0;
    const lstatSync = vi
      .fn<NonNullable<TmpDirOptions["lstatSync"]>>()
      .mockImplementationOnce(() => {
        throw nodeErrorWithCode("ENOENT");
      })
      .mockImplementation(() =>
        makeDirStat({
          mode: preferredMode,
        }),
      );
    const chmodSync = vi.fn((target: string, mode: number) => {
      chmodCalls += 1;
      if (target === POSIX_OPENCLAW_TMP_DIR && mode === 0o700 && chmodCalls > 1) {
        preferredMode = 0o40700;
      }
    });
    const warn = vi.fn();

    const { resolved, mkdirSync, tmpdir } = resolveWithMocks({
      lstatSync,
      chmodSync,
      warn,
    });

    expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR);
    expect(mkdirSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, {
      recursive: true,
      mode: 0o700,
    });
    expect(chmodSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, 0o700);
    expect(warn).toHaveBeenCalledWith(expect.stringContaining("tightened permissions on temp dir"));
    expect(tmpdir).not.toHaveBeenCalled();
  });

  it("throws when fallback path is a symlink", () => {
    const lstatSync = symlinkTmpDirLstat();
    const fallbackLstatSync = vi.fn(() => makeDirStat({ isSymbolicLink: true, mode: 0o120777 }));

    expect(() =>
      resolveWithMocks({
        lstatSync,
        fallbackLstatSync,
      }),
    ).toThrow(/Unsafe fallback OpenClaw temp dir/);
  });

  it("creates fallback directory when missing, then validates ownership and mode", () => {
    const lstatSync = symlinkTmpDirLstat();
    const fallbackLstatSync = missingThenSecureLstat();

    const { resolved, mkdirSync } = resolveWithMocks({
      lstatSync,
      fallbackLstatSync,
    });

    expect(resolved).toBe(fallbackTmp());
    expect(mkdirSync).toHaveBeenCalledWith(fallbackTmp(), { recursive: true, mode: 0o700 });
  });

  it("uses an unscoped fallback suffix when process uid is unavailable", () => {
    const tmpdirPath = "/var/fallback";
    const fallbackPath = path.join(tmpdirPath, "openclaw");

    const resolved = resolvePreferredOpenClawTmpDir({
      accessSync: vi.fn((target: string) => {
        if (target === "/tmp") {
          throw new Error("read-only");
        }
      }),
      lstatSync: vi.fn((target: string) => {
        if (target === POSIX_OPENCLAW_TMP_DIR) {
          throw nodeErrorWithCode("ENOENT");
        }
        if (target === fallbackPath) {
          return makeDirStat({ uid: 0, mode: 0o40777 });
        }
        return secureDirStat();
      }),
      mkdirSync: vi.fn(),
      chmodSync: vi.fn(),
      getuid: vi.fn(() => undefined),
      tmpdir: vi.fn(() => tmpdirPath),
      warn: vi.fn(),
    });

    expect(resolved).toBe(fallbackPath);
  });

  it("repairs fallback directory permissions after create when umask makes it group-writable", () => {
    const fallbackPath = fallbackTmp();
    let fallbackMode = 0o40775;
    const lstatSync = vi.fn<NonNullable<TmpDirOptions["lstatSync"]>>(() => {
      throw nodeErrorWithCode("ENOENT");
    });
    const fallbackLstatSync = vi
      .fn<NonNullable<TmpDirOptions["lstatSync"]>>()
      .mockImplementationOnce(() => {
        throw nodeErrorWithCode("ENOENT");
      })
      .mockImplementation(() => ({
        isDirectory: () => true,
        isSymbolicLink: () => false,
        uid: 501,
        mode: fallbackMode,
      }));
    const chmodSync = vi.fn((target: string, mode: number) => {
      if (target === fallbackPath && mode === 0o700) {
        fallbackMode = 0o40700;
      }
    });

    const resolved = resolveWithReadOnlyTmpFallback({
      fallbackPath,
      fallbackLstatSync: vi.fn((target: string) => {
        if (target === fallbackPath) {
          return fallbackLstatSync(target);
        }
        return lstatSync(target);
      }),
      chmodSync,
      warn: vi.fn(),
    });

    expect(resolved).toBe(fallbackPath);
    expect(chmodSync).toHaveBeenCalledWith(fallbackPath, 0o700);
  });

  it("repairs existing fallback directory when permissions are too broad", () => {
    const fallbackPath = fallbackTmp();
    let fallbackMode = 0o40775;
    const chmodSync = vi.fn((target: string, mode: number) => {
      if (target === fallbackPath && mode === 0o700) {
        fallbackMode = 0o40700;
      }
    });
    const warn = vi.fn();

    const resolved = resolveWithReadOnlyTmpFallback({
      fallbackPath,
      fallbackLstatSync: vi.fn(() =>
        makeDirStat({
          isSymbolicLink: false,
          mode: fallbackMode,
        }),
      ),
      chmodSync,
      warn,
    });

    expect(resolved).toBe(fallbackPath);
    expect(chmodSync).toHaveBeenCalledWith(fallbackPath, 0o700);
    expect(warn).toHaveBeenCalledWith(expect.stringContaining("tightened permissions on temp dir"));
  });

  it("throws when the fallback directory cannot be created", () => {
    expect(() =>
      resolvePreferredOpenClawTmpDir({
        accessSync: readOnlyTmpAccessSync(),
        lstatSync: vi.fn((target: string) => {
          if (target === POSIX_OPENCLAW_TMP_DIR || target === fallbackTmp()) {
            throw nodeErrorWithCode("ENOENT");
          }
          return secureDirStat();
        }),
        mkdirSync: vi.fn(() => {
          throw new Error("mkdir failed");
        }),
        chmodSync: vi.fn(),
        getuid: vi.fn(() => 501),
        tmpdir: vi.fn(() => "/var/fallback"),
        warn: vi.fn(),
      }),
    ).toThrow(/Unable to create fallback OpenClaw temp dir/);
  });
});
