#!/usr/bin/env node

/**
 * Advisory suppression tests for clawsec-suite.
 *
 * Tests cover:
 * - isAdvisorySuppressed matching logic (exact checkId + normalized skill name)
 * - Partial matches do not suppress (checkId only, skill only)
 * - Empty suppressions never suppress
 * - loadAdvisorySuppression sentinel gating (enabledFor: ["advisory"])
 * - Missing sentinel returns empty config
 * - Wrong sentinel (only "audit") returns empty config
 *
 * Run: node skills/clawsec-suite/test/advisory_suppression.test.mjs
 */

import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { pass, fail, report, exitWithResults, createTempDir } from "./lib/test_harness.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIB_PATH = path.resolve(__dirname, "..", "hooks", "clawsec-advisory-guardian", "lib");

const { isAdvisorySuppressed, loadAdvisorySuppression } = await import(
  `${LIB_PATH}/suppression.mjs`
);

let tempDir;

function makeMatch(advisoryId, skillName, version = "1.0.0") {
  return {
    advisory: { id: advisoryId, severity: "high", title: `Advisory ${advisoryId}` },
    skill: { name: skillName, dirName: skillName, version },
    matchedAffected: [`${skillName}@<=${version}`],
  };
}

function makeRules(entries) {
  return entries.map(([checkId, skill, reason]) => ({
    checkId,
    skill,
    reason: reason || "Test suppression",
    suppressedAt: "2026-02-15",
  }));
}

// ---------------------------------------------------------------------------
// isAdvisorySuppressed tests
// ---------------------------------------------------------------------------

async function testExactMatch() {
  const testName = "isAdvisorySuppressed: exact match suppresses";
  try {
    const match = makeMatch("CVE-2026-25593", "clawsec-suite");
    const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
    if (isAdvisorySuppressed(match, rules) === true) {
      pass(testName);
    } else {
      fail(testName, "Expected suppression but got false");
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testCaseInsensitiveSkillMatch() {
  const testName = "isAdvisorySuppressed: case-insensitive skill name match";
  try {
    const match = makeMatch("CVE-2026-25593", "ClawSec-Suite");
    const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
    if (isAdvisorySuppressed(match, rules) === true) {
      pass(testName);
    } else {
      fail(testName, "Expected case-insensitive match to suppress");
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testCheckIdMismatch() {
  const testName = "isAdvisorySuppressed: checkId mismatch does not suppress";
  try {
    const match = makeMatch("CVE-2026-99999", "clawsec-suite");
    const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
    if (isAdvisorySuppressed(match, rules) === false) {
      pass(testName);
    } else {
      fail(testName, "Expected no suppression for mismatched checkId");
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testSkillMismatch() {
  const testName = "isAdvisorySuppressed: skill mismatch does not suppress";
  try {
    const match = makeMatch("CVE-2026-25593", "other-skill");
    const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
    if (isAdvisorySuppressed(match, rules) === false) {
      pass(testName);
    } else {
      fail(testName, "Expected no suppression for mismatched skill");
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testEmptySuppressions() {
  const testName = "isAdvisorySuppressed: empty suppressions never suppress";
  try {
    const match = makeMatch("CVE-2026-25593", "clawsec-suite");
    if (isAdvisorySuppressed(match, []) === false) {
      pass(testName);
    } else {
      fail(testName, "Expected no suppression with empty rules");
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testMultipleRules() {
  const testName = "isAdvisorySuppressed: multiple rules match correct one";
  try {
    const match = makeMatch("CLAW-2026-0001", "openclaw-audit-watchdog");
    const rules = makeRules([
      ["CVE-2026-25593", "clawsec-suite"],
      ["CLAW-2026-0001", "openclaw-audit-watchdog"],
    ]);
    if (isAdvisorySuppressed(match, rules) === true) {
      pass(testName);
    } else {
      fail(testName, "Expected match against second rule");
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testMissingAdvisoryId() {
  const testName = "isAdvisorySuppressed: missing advisory.id does not suppress";
  try {
    const match = {
      advisory: { severity: "high", title: "No ID advisory" },
      skill: { name: "clawsec-suite", dirName: "clawsec-suite", version: "1.0.0" },
      matchedAffected: [],
    };
    const rules = makeRules([["CVE-2026-25593", "clawsec-suite"]]);
    if (isAdvisorySuppressed(match, rules) === false) {
      pass(testName);
    } else {
      fail(testName, "Expected no suppression when advisory has no id");
    }
  } catch (error) {
    fail(testName, error);
  }
}

// ---------------------------------------------------------------------------
// loadAdvisorySuppression tests
// ---------------------------------------------------------------------------

async function testLoadWithAdvisorySentinel() {
  const testName = "loadAdvisorySuppression: loads config with advisory sentinel";
  try {
    const configFile = path.join(tempDir.path, "advisory-config.json");
    await fs.writeFile(configFile, JSON.stringify({
      enabledFor: ["advisory"],
      suppressions: [{
        checkId: "CVE-2026-25593",
        skill: "clawsec-suite",
        reason: "First-party tooling",
        suppressedAt: "2026-02-15",
      }],
    }));

    const config = await loadAdvisorySuppression(configFile);
    if (config.suppressions.length === 1 && config.source === configFile) {
      pass(testName);
    } else {
      fail(testName, `Expected 1 suppression from ${configFile}, got: ${JSON.stringify(config)}`);
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testLoadWithMissingSentinel() {
  const testName = "loadAdvisorySuppression: missing sentinel returns empty config";
  try {
    const configFile = path.join(tempDir.path, "no-sentinel.json");
    await fs.writeFile(configFile, JSON.stringify({
      suppressions: [{
        checkId: "CVE-2026-25593",
        skill: "clawsec-suite",
        reason: "First-party tooling",
        suppressedAt: "2026-02-15",
      }],
    }));

    const config = await loadAdvisorySuppression(configFile);
    if (config.suppressions.length === 0) {
      pass(testName);
    } else {
      fail(testName, `Expected empty suppressions without sentinel, got: ${JSON.stringify(config)}`);
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testLoadWithAuditOnlySentinel() {
  const testName = "loadAdvisorySuppression: audit-only sentinel returns empty for advisory";
  try {
    const configFile = path.join(tempDir.path, "audit-only.json");
    await fs.writeFile(configFile, JSON.stringify({
      enabledFor: ["audit"],
      suppressions: [{
        checkId: "CVE-2026-25593",
        skill: "clawsec-suite",
        reason: "First-party tooling",
        suppressedAt: "2026-02-15",
      }],
    }));

    const config = await loadAdvisorySuppression(configFile);
    if (config.suppressions.length === 0) {
      pass(testName);
    } else {
      fail(testName, `Expected empty for audit-only sentinel, got: ${JSON.stringify(config)}`);
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testLoadWithBothSentinels() {
  const testName = "loadAdvisorySuppression: both audit+advisory sentinels activates advisory";
  try {
    const configFile = path.join(tempDir.path, "both-sentinel.json");
    await fs.writeFile(configFile, JSON.stringify({
      enabledFor: ["audit", "advisory"],
      suppressions: [{
        checkId: "CVE-2026-25593",
        skill: "clawsec-suite",
        reason: "First-party tooling",
        suppressedAt: "2026-02-15",
      }],
    }));

    const config = await loadAdvisorySuppression(configFile);
    if (config.suppressions.length === 1) {
      pass(testName);
    } else {
      fail(testName, `Expected 1 suppression with both sentinels, got: ${JSON.stringify(config)}`);
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testLoadNonexistentExplicitPath() {
  const testName = "loadAdvisorySuppression: explicit nonexistent path throws";
  try {
    await loadAdvisorySuppression(path.join(tempDir.path, "does-not-exist.json"));
    fail(testName, "Expected error for nonexistent explicit path");
  } catch (error) {
    if (String(error).includes("not found")) {
      pass(testName);
    } else {
      fail(testName, `Unexpected error: ${error}`);
    }
  }
}

async function testLoadNoConfigReturnsEmpty() {
  const testName = "loadAdvisorySuppression: no config available returns empty";
  try {
    // Clear env var to ensure no ambient config
    const savedEnv = process.env.OPENCLAW_AUDIT_CONFIG;
    delete process.env.OPENCLAW_AUDIT_CONFIG;

    try {
      // Call without explicit path and with no env var — falls through to default paths
      // which likely don't exist in test environment
      const config = await loadAdvisorySuppression();
      if (config.suppressions.length === 0 && config.source === "none") {
        pass(testName);
      } else {
        fail(testName, `Expected empty config, got: ${JSON.stringify(config)}`);
      }
    } finally {
      if (savedEnv !== undefined) process.env.OPENCLAW_AUDIT_CONFIG = savedEnv;
      else delete process.env.OPENCLAW_AUDIT_CONFIG;
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testEnvPathHomeExpansion() {
  const testName = "loadAdvisorySuppression: OPENCLAW_AUDIT_CONFIG expands $HOME";
  try {
    const configFile = path.join(tempDir.path, "env-home.json");
    await fs.writeFile(configFile, JSON.stringify({
      enabledFor: ["advisory"],
      suppressions: [{
        checkId: "CVE-2026-25593",
        skill: "clawsec-suite",
        reason: "Env home expansion",
        suppressedAt: "2026-02-15",
      }],
    }));

    const savedConfig = process.env.OPENCLAW_AUDIT_CONFIG;
    const savedHome = process.env.HOME;
    process.env.HOME = tempDir.path;
    process.env.OPENCLAW_AUDIT_CONFIG = "$HOME/env-home.json";
    try {
      const config = await loadAdvisorySuppression();
      if (config.suppressions.length === 1 && config.source === configFile) {
        pass(testName);
      } else {
        fail(testName, `Expected env-expanded config, got: ${JSON.stringify(config)}`);
      }
    } finally {
      if (savedConfig !== undefined) process.env.OPENCLAW_AUDIT_CONFIG = savedConfig;
      else delete process.env.OPENCLAW_AUDIT_CONFIG;
      if (savedHome !== undefined) process.env.HOME = savedHome;
      else delete process.env.HOME;
    }
  } catch (error) {
    fail(testName, error);
  }
}

async function testEscapedHomeTokenRejected() {
  const testName = "loadAdvisorySuppression: escaped home token is rejected";
  try {
    const savedEnv = process.env.OPENCLAW_AUDIT_CONFIG;
    process.env.OPENCLAW_AUDIT_CONFIG = "\\$HOME/not-real.json";
    try {
      await loadAdvisorySuppression();
      fail(testName, "Expected error for escaped token");
    } catch (error) {
      if (String(error).includes("Unexpanded home token")) {
        pass(testName);
      } else {
        fail(testName, `Unexpected error: ${error}`);
      }
    } finally {
      if (savedEnv !== undefined) process.env.OPENCLAW_AUDIT_CONFIG = savedEnv;
      else delete process.env.OPENCLAW_AUDIT_CONFIG;
    }
  } catch (error) {
    fail(testName, error);
  }
}

// ---------------------------------------------------------------------------
// Main test runner
// ---------------------------------------------------------------------------
async function runAllTests() {
  console.log("=== Advisory Suppression Tests ===\n");

  tempDir = await createTempDir();

  try {
    // isAdvisorySuppressed tests
    await testExactMatch();
    await testCaseInsensitiveSkillMatch();
    await testCheckIdMismatch();
    await testSkillMismatch();
    await testEmptySuppressions();
    await testMultipleRules();
    await testMissingAdvisoryId();

    // loadAdvisorySuppression tests
    await testLoadWithAdvisorySentinel();
    await testLoadWithMissingSentinel();
    await testLoadWithAuditOnlySentinel();
    await testLoadWithBothSentinels();
    await testLoadNonexistentExplicitPath();
    await testLoadNoConfigReturnsEmpty();
    await testEnvPathHomeExpansion();
    await testEscapedHomeTokenRejected();
  } finally {
    await tempDir.cleanup();
  }

  report();
  exitWithResults();
}

runAllTests().catch((err) => {
  console.error("Test runner failed:", err);
  process.exit(1);
});
