import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
import {
  clearDeviceBootstrapTokens,
  DEVICE_BOOTSTRAP_TOKEN_TTL_MS,
  getBoundDeviceBootstrapProfile,
  getDeviceBootstrapTokenProfile,
  issueDeviceBootstrapToken,
  redeemDeviceBootstrapTokenProfile,
  restoreDeviceBootstrapToken,
  revokeDeviceBootstrapToken,
  verifyDeviceBootstrapToken,
} from "./device-bootstrap.js";
import { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } from "./device-identity.js";

const tempDirs = createTrackedTempDirs();
const createTempDir = () => tempDirs.make("openclaw-device-bootstrap-test-");

function resolveBootstrapPath(baseDir: string): string {
  return path.join(baseDir, "devices", "bootstrap.json");
}

async function verifyBootstrapToken(
  baseDir: string,
  token: string,
  overrides: Partial<Parameters<typeof verifyDeviceBootstrapToken>[0]> = {},
) {
  return await verifyDeviceBootstrapToken({
    token,
    deviceId: "device-123",
    publicKey: "public-key-123",
    role: "node",
    scopes: [],
    baseDir,
    ...overrides,
  });
}

afterEach(async () => {
  vi.useRealTimers();
  await tempDirs.cleanup();
});

describe("device bootstrap tokens", () => {
  it("issues bootstrap tokens and persists them with an expiry", async () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-14T12:00:00Z"));

    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    expect(issued.token).toMatch(/^[A-Za-z0-9_-]+$/);
    expect(issued.expiresAtMs).toBe(Date.now() + DEVICE_BOOTSTRAP_TOKEN_TTL_MS);

    const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
    const parsed = JSON.parse(raw) as Record<
      string,
      {
        token: string;
        ts: number;
        issuedAtMs: number;
        profile: { roles: string[]; scopes: string[] };
      }
    >;
    expect(parsed[issued.token]).toMatchObject({
      token: issued.token,
      ts: Date.now(),
      issuedAtMs: Date.now(),
      profile: {
        roles: ["node", "operator"],
        scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
      },
    });
  });

  it("verifies valid bootstrap tokens and binds them to the first device identity", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
    await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });

    const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
    const parsed = JSON.parse(raw) as Record<
      string,
      {
        token: string;
        deviceId?: string;
        publicKey?: string;
      }
    >;
    expect(parsed[issued.token]).toMatchObject({
      token: issued.token,
      deviceId: "device-123",
      publicKey: "public-key-123",
    });
  });

  it("loads the issued bootstrap profile for a valid token", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(getDeviceBootstrapTokenProfile({ baseDir, token: issued.token })).resolves.toEqual(
      {
        roles: ["node", "operator"],
        scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
      },
    );
    await expect(getDeviceBootstrapTokenProfile({ baseDir, token: "invalid" })).resolves.toBeNull();
  });

  it("persists bootstrap redemption state across verification reloads", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
    await expect(
      redeemDeviceBootstrapTokenProfile({
        baseDir,
        token: issued.token,
        role: "node",
        scopes: [],
      }),
    ).resolves.toEqual({
      recorded: true,
      fullyRedeemed: false,
    });

    await expect(
      verifyBootstrapToken(baseDir, issued.token, {
        role: "operator",
        scopes: ["operator.approvals", "operator.read", "operator.write", "operator.talk.secrets"],
      }),
    ).resolves.toEqual({ ok: true });
    await expect(
      redeemDeviceBootstrapTokenProfile({
        baseDir,
        token: issued.token,
        role: "operator",
        scopes: ["operator.approvals", "operator.read", "operator.write", "operator.talk.secrets"],
      }),
    ).resolves.toEqual({
      recorded: true,
      fullyRedeemed: true,
    });
  });

  it("clears outstanding bootstrap tokens on demand", async () => {
    const baseDir = await createTempDir();
    const first = await issueDeviceBootstrapToken({ baseDir });
    const second = await issueDeviceBootstrapToken({ baseDir });

    await expect(clearDeviceBootstrapTokens({ baseDir })).resolves.toEqual({ removed: 2 });
    await expect(fs.readFile(resolveBootstrapPath(baseDir), "utf8")).resolves.toBe("{}");

    await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({
      ok: false,
      reason: "bootstrap_token_invalid",
    });

    await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({
      ok: false,
      reason: "bootstrap_token_invalid",
    });
  });

  it("restores a revoked bootstrap token record after send failure recovery", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
    const revoked = await revokeDeviceBootstrapToken({ baseDir, token: issued.token });
    expect(revoked.removed).toBe(true);
    expect(revoked.record?.token).toBe(issued.token);

    if (!revoked.record) {
      throw new Error("expected revoked bootstrap token record");
    }
    await restoreDeviceBootstrapToken({ baseDir, record: revoked.record });
    await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
  });

  it("revokes a specific bootstrap token", async () => {
    const baseDir = await createTempDir();
    const first = await issueDeviceBootstrapToken({ baseDir });
    const second = await issueDeviceBootstrapToken({ baseDir });

    await expect(
      revokeDeviceBootstrapToken({ baseDir, token: first.token }),
    ).resolves.toMatchObject({
      removed: true,
    });

    await expect(verifyBootstrapToken(baseDir, first.token)).resolves.toEqual({
      ok: false,
      reason: "bootstrap_token_invalid",
    });

    await expect(verifyBootstrapToken(baseDir, second.token)).resolves.toEqual({ ok: true });
  });

  it("verifies bootstrap tokens by the persisted map key and binds them", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });
    const issuedAtMs = Date.now();
    const bootstrapPath = path.join(baseDir, "devices", "bootstrap.json");
    await fs.writeFile(
      bootstrapPath,
      JSON.stringify(
        {
          "legacy-key": {
            token: issued.token,
            ts: issuedAtMs,
            issuedAtMs,
            profile: {
              roles: ["node", "operator"],
              scopes: [
                "operator.approvals",
                "operator.read",
                "operator.talk.secrets",
                "operator.write",
              ],
            },
          },
        },
        null,
        2,
      ),
      "utf8",
    );

    await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });

    const raw = await fs.readFile(bootstrapPath, "utf8");
    const parsed = JSON.parse(raw) as Record<
      string,
      { token: string; deviceId?: string; publicKey?: string }
    >;
    expect(parsed["legacy-key"]).toMatchObject({
      token: issued.token,
      deviceId: "device-123",
      publicKey: "public-key-123",
    });
  });

  it("keeps the token when required verification fields are blank", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(
      verifyBootstrapToken(baseDir, issued.token, {
        role: "   ",
      }),
    ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });

    const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
    expect(raw).toContain(issued.token);
  });

  it("rejects bootstrap verification when role or scopes exceed the issued profile", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(
      verifyBootstrapToken(baseDir, issued.token, {
        role: "operator",
        scopes: ["operator.admin"],
      }),
    ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });

    const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
    expect(raw).toContain(issued.token);
  });

  it("allows operator scope subsets within the issued bootstrap profile", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(
      verifyBootstrapToken(baseDir, issued.token, {
        role: "operator",
        scopes: ["operator.read"],
      }),
    ).resolves.toEqual({ ok: true });
  });

  it("rejects cross-role scope escalation (node role requesting operator scopes)", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(
      verifyBootstrapToken(baseDir, issued.token, {
        role: "node",
        scopes: ["operator.read"],
      }),
    ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });

    const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
    expect(raw).toContain(issued.token);
  });

  it("supports explicitly bound bootstrap profiles", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({
      baseDir,
      profile: {
        roles: [" operator ", "operator"],
        scopes: ["operator.read", " operator.read "],
      },
    });

    const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
    const parsed = JSON.parse(raw) as Record<
      string,
      { profile: { roles: string[]; scopes: string[] } }
    >;
    expect(parsed[issued.token]?.profile).toEqual({
      roles: ["operator"],
      scopes: ["operator.read"],
    });

    await expect(
      verifyBootstrapToken(baseDir, issued.token, {
        role: "operator",
        scopes: ["operator.read"],
      }),
    ).resolves.toEqual({ ok: true });
  });

  it("accepts trimmed bootstrap tokens and binds them", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(verifyBootstrapToken(baseDir, `  ${issued.token}  `)).resolves.toEqual({
      ok: true,
    });

    const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
    const parsed = JSON.parse(raw) as Record<string, { deviceId?: string }>;
    expect(parsed[issued.token]?.deviceId).toBe("device-123");
  });

  it("rejects blank or unknown tokens", async () => {
    const baseDir = await createTempDir();
    await issueDeviceBootstrapToken({ baseDir });

    await expect(verifyBootstrapToken(baseDir, "   ")).resolves.toEqual({
      ok: false,
      reason: "bootstrap_token_invalid",
    });

    await expect(
      verifyDeviceBootstrapToken({
        token: "missing-token",
        deviceId: "device-123",
        publicKey: "public-key-123",
        role: "node",
        scopes: [],
        baseDir,
      }),
    ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
  });

  it("repairs malformed persisted state when issuing a new token", async () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-14T12:00:00Z"));

    const baseDir = await createTempDir();
    const bootstrapPath = resolveBootstrapPath(baseDir);
    await fs.mkdir(path.dirname(bootstrapPath), { recursive: true });
    await fs.writeFile(bootstrapPath, "[1,2,3]\n", "utf8");

    const issued = await issueDeviceBootstrapToken({ baseDir });
    const raw = await fs.readFile(bootstrapPath, "utf8");
    const parsed = JSON.parse(raw) as Record<string, { token: string }>;

    expect(Object.keys(parsed)).toEqual([issued.token]);
    expect(parsed[issued.token]?.token).toBe(issued.token);
  });

  it("accepts equivalent public key encodings after binding the bootstrap token", async () => {
    const baseDir = await createTempDir();
    const identity = loadOrCreateDeviceIdentity(path.join(baseDir, "device.json"));
    const issued = await issueDeviceBootstrapToken({ baseDir });
    const rawPublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);

    await expect(
      verifyBootstrapToken(baseDir, issued.token, {
        deviceId: identity.deviceId,
        publicKey: identity.publicKeyPem,
      }),
    ).resolves.toEqual({ ok: true });
    await expect(
      verifyBootstrapToken(baseDir, issued.token, {
        deviceId: identity.deviceId,
        publicKey: rawPublicKey,
      }),
    ).resolves.toEqual({ ok: true });
    await expect(
      getBoundDeviceBootstrapProfile({
        token: issued.token,
        deviceId: identity.deviceId,
        publicKey: rawPublicKey,
        baseDir,
      }),
    ).resolves.toEqual({
      roles: ["node", "operator"],
      scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
    });
  });

  it("rejects a second device identity after the first verification binds the token", async () => {
    const baseDir = await createTempDir();
    const issued = await issueDeviceBootstrapToken({ baseDir });

    await expect(verifyBootstrapToken(baseDir, issued.token)).resolves.toEqual({ ok: true });
    await expect(
      verifyBootstrapToken(baseDir, issued.token, {
        deviceId: "device-456",
        publicKey: "public-key-456",
      }),
    ).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
  });

  it("fails closed for unbound legacy records and prunes expired tokens", async () => {
    vi.useFakeTimers();
    const baseDir = await createTempDir();
    const bootstrapPath = resolveBootstrapPath(baseDir);
    await fs.mkdir(path.dirname(bootstrapPath), { recursive: true });

    vi.setSystemTime(new Date("2026-03-14T12:00:00Z"));
    await fs.writeFile(
      bootstrapPath,
      `${JSON.stringify(
        {
          legacyToken: {
            token: "legacyToken",
            issuedAtMs: Date.now(),
          },
          expiredToken: {
            token: "expiredToken",
            issuedAtMs: Date.now() - DEVICE_BOOTSTRAP_TOKEN_TTL_MS - 1,
          },
        },
        null,
        2,
      )}\n`,
      "utf8",
    );

    await expect(verifyBootstrapToken(baseDir, "legacyToken")).resolves.toEqual({
      ok: false,
      reason: "bootstrap_token_invalid",
    });

    await expect(verifyBootstrapToken(baseDir, "expiredToken")).resolves.toEqual({
      ok: false,
      reason: "bootstrap_token_invalid",
    });
  });
});
