// Covers plugin trust audit findings and remediation hints.
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 type { OpenClawConfig } from "../config/config.js";
import type { HookInstallRecord } from "../config/types.hooks.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { writePersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js";
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
import { writeConfigMachineState } from "../state/config-machine-state.js";
import {
  captureEnv,
  createPathResolutionEnv,
  deleteTestEnvValue,
  setTestEnvValue,
  withEnvAsync,
} from "../test-utils/env.js";

type CollectPluginsTrustFindings =
  typeof import("./audit-plugins-trust.js").collectPluginsTrustFindings;

async function collectPluginsTrustFindingsForTest(
  ...args: Parameters<CollectPluginsTrustFindings>
): Promise<Awaited<ReturnType<CollectPluginsTrustFindings>>> {
  vi.resetModules();
  const { collectPluginsTrustFindings } = await import("./audit-plugins-trust.js");
  return await collectPluginsTrustFindings(...args);
}

const mockChannelPlugins = vi.hoisted(() => [
  {
    id: "discord",
    capabilities: {},
    commands: {},
    config: {
      listAccountIds: () => [],
      resolveAccount: () => null,
    },
  },
]);
const mockPluginRegistryIds = vi.hoisted(() => [
  "active-memory",
  "anthropic",
  "brave",
  "discord",
  "google",
  "lmstudio",
  "memory-core",
  "ollama",
]);

const readInstalledPackageVersionMock = vi.hoisted(() =>
  vi.fn(async (dir: string) => {
    if (dir.includes("/extensions/voice-call") || dir.includes("\\extensions\\voice-call")) {
      return "9.9.9";
    }
    if (dir.includes("/hooks/test-hooks") || dir.includes("\\hooks\\test-hooks")) {
      return "8.8.8";
    }
    return undefined;
  }),
);

vi.mock("../infra/package-update-utils.js", () => ({
  readInstalledPackageVersion: readInstalledPackageVersionMock,
}));

vi.mock("../plugins/config-state.js", () => ({
  normalizePluginId: (id: string) => id,
  resolveEffectiveEnableState: (params: {
    config?: {
      enabled?: boolean;
      deny?: string[];
      allow?: string[];
      entries?: Record<string, { enabled?: boolean }>;
    };
    id: string;
    enabledByDefault?: boolean;
  }) => {
    const entry = params.config?.entries?.[params.id];
    const denied = params.config?.deny?.includes(params.id) === true;
    const allowed =
      !params.config?.allow?.length ||
      params.config.allow.includes(params.id) ||
      params.config.allow.includes("group:plugins");
    const enabled =
      params.config?.enabled !== false &&
      !denied &&
      allowed &&
      entry?.enabled !== false &&
      (entry?.enabled === true || params.enabledByDefault === true);
    return {
      enabled,
      activated: enabled,
      reason: enabled ? "enabled" : "disabled",
    };
  },
  normalizePluginsConfig: (
    config:
      | {
          allow?: string[];
          deny?: string[];
          enabled?: boolean;
          entries?: Record<string, { enabled?: boolean }>;
        }
      | undefined,
  ) => ({
    allow: config?.allow ?? [],
    deny: config?.deny ?? [],
    enabled: config?.enabled !== false,
    entries: config?.entries ?? {},
  }),
}));

vi.mock("../plugins/plugin-registry.js", () => ({
  createPluginRegistryIdNormalizer: () => (id: string) => id,
  loadPluginRegistrySnapshot: () => ({
    diagnostics: [],
    plugins: mockPluginRegistryIds.map((pluginId) => ({ pluginId })),
  }),
}));

vi.mock("../config/commands.js", () => ({
  resolveNativeSkillsEnabled: ({
    globalSetting,
    providerSetting,
  }: {
    globalSetting?: boolean | "auto";
    providerSetting?: boolean | "auto";
  }) => providerSetting === true || (providerSetting === undefined && globalSetting === true),
}));

vi.mock("../channels/plugins/read-only.js", () => ({
  listReadOnlyChannelPluginsForConfig: () => mockChannelPlugins,
}));

vi.mock("../channels/read-only-account-inspect.js", () => ({
  inspectReadOnlyChannelAccount: () => null,
}));

vi.mock("../agents/sandbox/config.js", () => ({
  resolveSandboxConfigForAgent: () => ({ mode: "off" }),
}));

vi.mock("../agents/sandbox/tool-policy.js", () => ({
  resolveSandboxToolPolicyForAgent: () => undefined,
}));

vi.mock("../agents/tool-policy-match.js", () => ({
  isToolAllowedByPolicies: (_tool: string, policies: unknown[]) =>
    policies.every((policy) => policy == null),
}));

vi.mock("../agents/tool-policy.js", () => ({
  resolveToolProfilePolicy: (profile: unknown) =>
    profile === "coding" || profile === "minimal" ? {} : undefined,
}));

vi.mock("../agents/sandbox-tool-policy.js", () => ({
  pickSandboxToolPolicy: () => undefined,
}));

describe("security audit install metadata findings", () => {
  let fixtureRoot = "";
  let caseId = 0;

  const makeTmpDir = async (label: string) => {
    const dir = path.join(fixtureRoot, `case-${caseId++}-${label}`);
    await fs.mkdir(dir, { recursive: true });
    return dir;
  };

  const runInstallMetadataAudit = async (cfg: OpenClawConfig, stateDir: string) => {
    return await collectPluginsTrustFindingsForTest({ cfg, stateDir });
  };

  const writeHookInstalls = (
    stateDir: string,
    installs: Record<string, HookInstallRecord>,
  ): void => {
    writeConfigMachineState("hooks.internal.installs", installs, {
      env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
    });
  };

  const requireInstallFinding = (
    findings: Awaited<ReturnType<typeof runInstallMetadataAudit>>,
    checkId: string,
  ) => {
    const finding = findings.find((entry) => entry.checkId === checkId);
    if (!finding) {
      throw new Error(`Expected ${checkId} finding`);
    }
    return finding;
  };

  const writePluginIndexInstallRecords = async (
    stateDir: string,
    records: Record<string, PluginInstallRecord>,
  ) => {
    const index: InstalledPluginIndex = {
      version: 1,
      hostContractVersion: "2026.4.25",
      compatRegistryVersion: "compat",
      migrationVersion: 1,
      policyHash: "policy",
      generatedAtMs: Date.now(),
      installRecords: records,
      plugins: Object.keys(records).map((pluginId) => ({
        pluginId,
        manifestPath: path.join(stateDir, "extensions", pluginId, "openclaw.plugin.json"),
        manifestHash: "manifest",
        rootDir: path.join(stateDir, "extensions", pluginId),
        origin: "global" as const,
        enabled: true,
        startup: {
          sidecar: true,
          memory: false,
          agentHarnesses: [],
        },
        compat: [],
      })),
      diagnostics: [],
    };
    await writePersistedInstalledPluginIndex(index, { stateDir });
  };

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

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

  it("evaluates install metadata findings", async () => {
    const cases: Array<{
      name: string;
      run: () => Promise<Awaited<ReturnType<typeof runInstallMetadataAudit>>>;
      expectedPresent?: readonly string[];
      expectedAbsent?: readonly string[];
    }> = [
      {
        name: "warns on unpinned npm install specs and missing integrity metadata",
        run: async () => {
          const stateDir = await makeTmpDir("unpinned-plugin-index");
          await writePluginIndexInstallRecords(stateDir, {
            "voice-call": {
              source: "npm",
              spec: "@openclaw/voice-call",
            },
          });
          writeHookInstalls(stateDir, {
            "test-hooks": { source: "npm", spec: "@openclaw/test-hooks" },
          });
          return runInstallMetadataAudit({}, stateDir);
        },
        expectedPresent: [
          "plugins.installs_unpinned_npm_specs",
          "plugins.installs_missing_integrity",
          "hooks.installs_unpinned_npm_specs",
          "hooks.installs_missing_integrity",
        ],
      },
      {
        name: "does not warn on pinned npm install specs with integrity metadata",
        run: async () => {
          const stateDir = await makeTmpDir("pinned-plugin-index");
          await writePluginIndexInstallRecords(stateDir, {
            "voice-call": {
              source: "npm",
              spec: "@openclaw/voice-call@1.2.3",
              integrity: "sha512-plugin",
            },
          });
          writeHookInstalls(stateDir, {
            "test-hooks": {
              source: "npm",
              spec: "@openclaw/test-hooks@1.2.3",
              integrity: "sha512-hook",
            },
          });
          return runInstallMetadataAudit({}, stateDir);
        },
        expectedAbsent: [
          "plugins.installs_unpinned_npm_specs",
          "plugins.installs_missing_integrity",
          "hooks.installs_unpinned_npm_specs",
          "hooks.installs_missing_integrity",
        ],
      },
      {
        name: "still warns when active npm specs are unpinned even with resolved metadata",
        run: async () => {
          const stateDir = await makeTmpDir("unpinned-active-spec-resolved-plugin-index");
          await writePluginIndexInstallRecords(stateDir, {
            "voice-call": {
              source: "npm",
              spec: "@openclaw/voice-call",
              resolvedSpec: "@openclaw/voice-call@1.2.3",
              integrity: "sha512-plugin",
            },
          });
          writeHookInstalls(stateDir, {
            "test-hooks": {
              source: "npm",
              spec: "@openclaw/test-hooks",
              resolvedSpec: "@openclaw/test-hooks@1.2.3",
              integrity: "sha512-hook",
            },
          });
          return runInstallMetadataAudit({}, stateDir);
        },
        expectedPresent: [
          "plugins.installs_unpinned_npm_specs",
          "hooks.installs_unpinned_npm_specs",
        ],
        expectedAbsent: ["plugins.installs_missing_integrity", "hooks.installs_missing_integrity"],
      },
      {
        name: "warns when install records drift from installed package versions",
        run: async () => {
          const stateDir = await makeTmpDir("drift-plugin-index");
          await writePluginIndexInstallRecords(stateDir, {
            "voice-call": {
              source: "npm",
              spec: "@openclaw/voice-call@1.2.3",
              integrity: "sha512-plugin",
              resolvedVersion: "1.2.3",
            },
          });
          writeHookInstalls(stateDir, {
            "test-hooks": {
              source: "npm",
              spec: "@openclaw/test-hooks@1.2.3",
              integrity: "sha512-hook",
              resolvedVersion: "1.2.3",
            },
          });
          return runInstallMetadataAudit({}, stateDir);
        },
        expectedPresent: ["plugins.installs_version_drift", "hooks.installs_version_drift"],
      },
    ];

    for (const testCase of cases) {
      const findings = await testCase.run();
      for (const checkId of testCase.expectedPresent ?? []) {
        expect(
          findings.some((finding) => finding.checkId === checkId && finding.severity === "warn"),
          testCase.name,
        ).toBe(true);
      }
      for (const checkId of testCase.expectedAbsent ?? []) {
        expect(
          findings.some((finding) => finding.checkId === checkId),
          testCase.name,
        ).toBe(false);
      }
    }
  });

  it("evaluates phantom allowlist findings", async () => {
    const bundledStateDir = await makeTmpDir("phantom-bundled-excluded");
    await fs.mkdir(path.join(bundledStateDir, "extensions", "some-installed-plugin"), {
      recursive: true,
    });

    const bundledFindings = await runInstallMetadataAudit(
      {
        plugins: { allow: ["discord", "some-installed-plugin"] },
      },
      bundledStateDir,
    );
    expect(
      bundledFindings.find((finding) => finding.checkId === "plugins.allow_phantom_entries"),
    ).toBeUndefined();

    const reportedStateDir = await makeTmpDir("phantom-reported");
    await fs.mkdir(path.join(reportedStateDir, "extensions", "installed-plugin"), {
      recursive: true,
    });

    const reportedFindings = await runInstallMetadataAudit(
      {
        plugins: { allow: ["installed-plugin", "ghost-plugin-xyz"] },
      },
      reportedStateDir,
    );
    const phantomFinding = requireInstallFinding(reportedFindings, "plugins.allow_phantom_entries");
    expect(phantomFinding.severity).toBe("warn");
    expect(phantomFinding.detail).toContain("ghost-plugin-xyz");
    expect(phantomFinding.detail).not.toContain("installed-plugin");
  });

  it("ignores install backup and debris dirs when auditing installed plugin roots", async () => {
    const stateDir = await makeTmpDir("installed-plugin-debris");
    for (const name of [
      "live-plugin",
      ".openclaw-install-backups",
      "node_modules",
      "old-plugin.backup-20260502",
      "old-plugin.disabled.20260502",
      "old-plugin.bak",
    ]) {
      await fs.mkdir(path.join(stateDir, "extensions", name), {
        recursive: true,
      });
    }

    const findings = await runInstallMetadataAudit({}, stateDir);

    const noAllowlist = requireInstallFinding(findings, "plugins.extensions_no_allowlist");
    expect(noAllowlist.detail).toContain("Found 1 extension(s)");

    const toolsReachable = requireInstallFinding(
      findings,
      "plugins.tools_reachable_permissive_policy",
    );
    expect(toolsReachable.detail).toContain("Enabled extension plugins: live-plugin.");
    expect(findings.map((finding) => finding.detail).join("\n")).not.toContain(
      ".openclaw-install-backups",
    );
  });

  it("does not report bundled provider and utility plugins as phantom allowlist entries", async () => {
    const stateDir = await makeTmpDir("phantom-bundled-providers");
    await fs.mkdir(path.join(stateDir, "extensions", "installed-plugin"), {
      recursive: true,
    });

    const findings = await runInstallMetadataAudit(
      {
        plugins: {
          allow: [
            "active-memory",
            "anthropic",
            "brave",
            "google",
            "lmstudio",
            "memory-core",
            "ollama",
            "installed-plugin",
          ],
        },
      },
      stateDir,
    );

    expect(
      findings.find((finding) => finding.checkId === "plugins.allow_phantom_entries"),
    ).toBeUndefined();
  });
});

describe("security audit extension tool reachability findings", () => {
  let fixtureRoot = "";
  let sharedExtensionsStateDir = "";
  let isolatedHome = "";
  let homedirSpy: { mockRestore(): void } | undefined;
  const pathResolutionEnvKeys = [
    "HOME",
    "USERPROFILE",
    "HOMEDRIVE",
    "HOMEPATH",
    "OPENCLAW_HOME",
    "OPENCLAW_STATE_DIR",
    "OPENCLAW_BUNDLED_PLUGINS_DIR",
  ] as const;
  let pathResolutionEnvSnapshot: ReturnType<typeof captureEnv> | undefined;

  const runSharedExtensionsAudit = async (config: OpenClawConfig) => {
    return await collectPluginsTrustFindingsForTest({
      cfg: config,
      stateDir: sharedExtensionsStateDir,
    });
  };

  beforeAll(async () => {
    const osModule = await import("node:os");
    const vitestModule = await import("vitest");
    fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-extensions-"));
    isolatedHome = path.join(fixtureRoot, "home");
    const isolatedEnv = createPathResolutionEnv(isolatedHome, { OPENCLAW_HOME: isolatedHome });
    pathResolutionEnvSnapshot = captureEnv([...pathResolutionEnvKeys]);
    for (const key of pathResolutionEnvKeys) {
      const value = isolatedEnv[key];
      if (value === undefined) {
        deleteTestEnvValue(key);
      } else {
        setTestEnvValue(key, value);
      }
    }
    homedirSpy = vitestModule.vi
      .spyOn(osModule.default ?? osModule, "homedir")
      .mockReturnValue(isolatedHome);
    await fs.mkdir(isolatedHome, { recursive: true, mode: 0o700 });
    sharedExtensionsStateDir = path.join(fixtureRoot, "shared-extensions-state");
    await fs.mkdir(path.join(sharedExtensionsStateDir, "extensions", "some-plugin"), {
      recursive: true,
      mode: 0o700,
    });
  });

  afterAll(async () => {
    homedirSpy?.mockRestore();
    pathResolutionEnvSnapshot?.restore();
    if (fixtureRoot) {
      await fs.rm(fixtureRoot, { recursive: true, force: true }).catch(() => undefined);
    }
  });

  it("evaluates extension tool reachability findings", async () => {
    const cases = [
      {
        name: "flags extensions without plugins.allow",
        cfg: {} satisfies OpenClawConfig,
        assert: (findings: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
          expect(
            findings.some(
              (finding) =>
                finding.checkId === "plugins.extensions_no_allowlist" &&
                finding.severity === "warn",
            ),
          ).toBe(true);
        },
      },
      {
        name: "flags enabled extensions when tool policy can expose plugin tools",
        cfg: {
          plugins: { allow: ["some-plugin"] },
        } satisfies OpenClawConfig,
        assert: (findings: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
          expect(
            findings.some(
              (finding) =>
                finding.checkId === "plugins.tools_reachable_permissive_policy" &&
                finding.severity === "warn",
            ),
          ).toBe(true);
        },
      },
      {
        name: "reports canonical agent paths for permissive tool policy",
        cfg: {
          plugins: { allow: ["some-plugin"] },
          agents: { entries: { ops: { tools: { profile: "full" } } } },
        } satisfies OpenClawConfig,
        assert: (findings: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
          const finding = findings.find(
            (entry) => entry.checkId === "plugins.tools_reachable_permissive_policy",
          );
          expect(finding?.detail).toContain("- agents.entries.ops");
        },
      },
      {
        name: "does not flag plugin tool reachability when profile is restrictive",
        cfg: {
          plugins: { allow: ["some-plugin"] },
          tools: { profile: "coding" },
        } satisfies OpenClawConfig,
        assert: (findings: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
          expect(
            findings.some(
              (finding) => finding.checkId === "plugins.tools_reachable_permissive_policy",
            ),
          ).toBe(false);
        },
      },
      {
        name: "flags unallowlisted extensions as warn-level findings when extension inventory exists",
        cfg: {
          channels: {
            discord: { enabled: true, token: "t" },
          },
        } satisfies OpenClawConfig,
        assert: (findings: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
          expect(
            findings.some(
              (finding) =>
                finding.checkId === "plugins.extensions_no_allowlist" &&
                finding.severity === "warn",
            ),
          ).toBe(true);
        },
      },
      {
        name: "treats SecretRef channel credentials as configured for extension allowlist severity",
        cfg: {
          channels: {
            discord: {
              enabled: true,
              token: {
                source: "env",
                provider: "default",
                id: "DISCORD_BOT_TOKEN",
              } as unknown as string,
            },
          },
        } satisfies OpenClawConfig,
        assert: (findings: Awaited<ReturnType<typeof runSharedExtensionsAudit>>) => {
          expect(
            findings.some(
              (finding) =>
                finding.checkId === "plugins.extensions_no_allowlist" &&
                finding.severity === "warn",
            ),
          ).toBe(true);
        },
      },
    ] as const;

    await withEnvAsync(
      {
        DISCORD_BOT_TOKEN: undefined,
        TELEGRAM_BOT_TOKEN: undefined,
        SLACK_BOT_TOKEN: undefined,
        SLACK_APP_TOKEN: undefined,
      },
      async () => {
        for (const testCase of cases) {
          testCase.assert(await runSharedExtensionsAudit(testCase.cfg));
        }
      },
    );
  });
});
