import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
  LocalSecretEnvelope,
  loadLocalSecretKeyring,
  type SecretBinding,
} from "../../src/security/secretEnvelope.js";

const createdDirectories: string[] = [];

afterEach(async () => {
  await Promise.all(createdDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});

describe("local secret envelope", () => {
  it("encrypts with a per-secret DEK and authenticates the complete binding", async () => {
    const keyring = await keyringFile({ active: key("a"), previous: key("b") }, "active");
    const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({
      NODE_ENV: "test",
      HUB_SECRET_KEYRING_FILE: keyring,
    }));
    const binding = secretBinding();
    const plaintext = {
      schemaVersion: 1,
      baseUrl: "https://provider.example/api",
      authToken: "org-a-secret-token",
      anthropicApiKey: "org-a-api-key",
    };

    const envelope = secrets.encryptJson(binding, plaintext);

    expect(envelope).toMatchObject({
      version: 1,
      algorithm: "AES-256-GCM",
      keyId: "active",
    });
    expect(JSON.stringify(envelope)).not.toContain(plaintext.authToken);
    expect(envelope.wrappedDek.ciphertext).not.toBe(envelope.payload.ciphertext);
    expect(secrets.decryptJson(binding, envelope)).toEqual(plaintext);
  });

  it.each([
    ["organization", { organizationId: "org-b" }],
    ["connection", { connectionId: "connection-b" }],
    ["version", { secretVersionId: "secret-version-b" }],
    ["purpose", { purpose: "feishu-application" }],
  ])("rejects ciphertext substituted across %s scope", async (_label, changed) => {
    const keyring = await keyringFile({ active: key("a") }, "active");
    const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({
      NODE_ENV: "test",
      HUB_SECRET_KEYRING_FILE: keyring,
    }));
    const binding = secretBinding();
    const envelope = secrets.encryptJson(binding, { token: "never-log-this-token" });

    expect(() => secrets.decryptJson({ ...binding, ...changed }, envelope)).toThrow(
      "secret envelope authentication failed",
    );
  });

  it("fails closed for a missing KEK or corrupted ciphertext without including plaintext", async () => {
    const encryptingKeyring = await keyringFile({ encrypting: key("a") }, "encrypting");
    const decryptingKeyring = await keyringFile({ other: key("b") }, "other");
    const encrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({
      NODE_ENV: "test",
      HUB_SECRET_KEYRING_FILE: encryptingKeyring,
    }));
    const decrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({
      NODE_ENV: "test",
      HUB_SECRET_KEYRING_FILE: decryptingKeyring,
    }));
    const binding = secretBinding();
    const envelope = encrypting.encryptJson(binding, { token: "never-log-this-token" });

    expect(() => decrypting.decryptJson(binding, envelope)).toThrow("secret envelope key is unavailable");

    const corrupted = structuredClone(envelope);
    corrupted.payload.ciphertext = flipFirstByte(corrupted.payload.ciphertext);
    let message = "";
    try {
      encrypting.decryptJson(binding, corrupted);
    } catch (error) {
      message = error instanceof Error ? error.message : String(error);
    }
    expect(message).toBe("secret envelope authentication failed");
    expect(message).not.toContain("never-log-this-token");
  });

  it("rewraps only the DEK when the active KEK rotates", async () => {
    const firstFile = await keyringFile({ old: key("a") }, "old");
    const rotatedFile = await keyringFile({ old: key("a"), current: key("c") }, "current");
    const first = new LocalSecretEnvelope(await loadLocalSecretKeyring({
      NODE_ENV: "test",
      HUB_SECRET_KEYRING_FILE: firstFile,
    }));
    const rotated = new LocalSecretEnvelope(await loadLocalSecretKeyring({
      NODE_ENV: "test",
      HUB_SECRET_KEYRING_FILE: rotatedFile,
    }));
    const binding = secretBinding();
    const envelope = first.encryptJson(binding, { token: "rotatable" });

    const rewrapped = rotated.rewrap(binding, envelope);

    expect(rewrapped.keyId).toBe("current");
    expect(rewrapped.payload).toEqual(envelope.payload);
    expect(rewrapped.wrappedDek).not.toEqual(envelope.wrappedDek);
    expect(rotated.decryptJson(binding, rewrapped)).toEqual({ token: "rotatable" });
  });

  it("loads production keys only from the fixed systemd credential", async () => {
    const credentialsDirectory = await temporaryDirectory();
    await writeKeyring(path.join(credentialsDirectory, "cph-secret-keyring"), { active: key("a") }, "active");

    await expect(loadLocalSecretKeyring({
      NODE_ENV: "production",
      CREDENTIALS_DIRECTORY: credentialsDirectory,
    })).resolves.toMatchObject({ activeKeyId: "active" });

    await expect(loadLocalSecretKeyring({
      NODE_ENV: "production",
      CREDENTIALS_DIRECTORY: credentialsDirectory,
      HUB_SECRET_KEYRING_FILE: "/tmp/untrusted-keyring.json",
    })).rejects.toThrow("production secret keyring must use the systemd credential");
  });

  it("rejects absent, malformed, or incomplete keyrings", async () => {
    await expect(loadLocalSecretKeyring({ NODE_ENV: "test" })).rejects.toThrow(
      "missing required secret keyring setting",
    );

    const directory = await temporaryDirectory();
    const malformed = path.join(directory, "malformed.json");
    await writeFile(malformed, "not json", "utf8");
    await expect(loadLocalSecretKeyring({
      NODE_ENV: "test",
      HUB_SECRET_KEYRING_FILE: malformed,
    })).rejects.toThrow("invalid local secret keyring");

    const missingActive = await keyringFile({ old: key("a") }, "current");
    await expect(loadLocalSecretKeyring({
      NODE_ENV: "test",
      HUB_SECRET_KEYRING_FILE: missingActive,
    })).rejects.toThrow("active key is unavailable");
  });
});

function secretBinding(): SecretBinding {
  return {
    purpose: "provider-connection",
    organizationId: "org-a",
    connectionId: "connection-a",
    secretVersionId: "secret-version-a",
  };
}

function key(character: string): string {
  return Buffer.alloc(32, character).toString("base64");
}

async function keyringFile(keys: Readonly<Record<string, string>>, activeKeyId: string): Promise<string> {
  const directory = await temporaryDirectory();
  const file = path.join(directory, "keyring.json");
  await writeKeyring(file, keys, activeKeyId);
  return file;
}

async function temporaryDirectory(): Promise<string> {
  const directory = await mkdtemp(path.join(tmpdir(), "cph-secret-envelope-"));
  createdDirectories.push(directory);
  return directory;
}

async function writeKeyring(
  file: string,
  keys: Readonly<Record<string, string>>,
  activeKeyId: string,
): Promise<void> {
  await writeFile(file, JSON.stringify({ version: 1, activeKeyId, keys }), { encoding: "utf8", mode: 0o600 });
}

function flipFirstByte(value: string): string {
  const bytes = Buffer.from(value, "base64");
  bytes[0] = (bytes[0] ?? 0) ^ 1;
  return bytes.toString("base64");
}
