/**
 * Fleet factory enrollment + scoped tokens (§12). Unit tests run the lifecycle
 * directly on the memory provider; route tests use Fastify inject to exercise the
 * GATED token enforcement (default OFF) on heartbeat/claim + the management routes.
 * Auth + productId resolution are mocked exactly like the other fleet routes tests.
 */

// Select the in-memory blob provider before the storage singleton is created
// (the artifact upload route test exercises blob storage).
process.env.STORAGE_PROVIDER = 'memory';

import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js';
import * as repo from './repository.js';
import * as enrollment from './enrollment.js';

vi.mock('../../lib/auth.js', () => ({
  extractAuth: vi.fn(async () => ({ sub: 'user_1', role: 'admin' })),
}));
vi.mock('../../lib/request-context.js', () => ({
  getRequestProductId: () => 'lysnrai',
  requireProductAccess: async () => 'lysnrai',
}));

const PID = 'lysnrai';

async function buildApp(): Promise<FastifyInstance> {
  const { fleetRoutes } = await import('./routes.js');
  const app = Fastify({ logger: false });
  await app.register(fleetRoutes, { prefix: '/api' });
  return app;
}

describe('fleet enrollment + scoped tokens', () => {
  const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
  beforeEach(() => {
    setProvider(new MemoryDatastoreProvider());
    delete process.env.FLEET_REQUIRE_FACTORY_TOKEN;
    delete process.env.FLEET_TOKEN_ROTATION_OVERLAP_SEC;
    delete process.env.FLEET_TOKEN_TTL_DAYS;
  });
  afterEach(() => {
    _resetDatastoreProvider();
    delete process.env.FLEET_REQUIRE_FACTORY_TOKEN;
    delete process.env.FLEET_TOKEN_ROTATION_OVERLAP_SEC;
    delete process.env.FLEET_TOKEN_TTL_DAYS;
    process.env.NODE_ENV = ORIGINAL_NODE_ENV;
    vi.clearAllMocks();
  });

  // ── Lifecycle (unit) ──
  it('enroll returns a plaintext token once; stores ONLY a hash + scope', async () => {
    const res = await enrollment.enrollFactory({
      productId: PID,
      factoryId: 'fac_1',
      capabilities: ['os:mac', 'engine:devin'],
      label: 'macbook',
    });
    expect(res.token).toMatch(/^flt_/);
    expect(res.scope).toEqual({
      productId: PID,
      factoryId: 'fac_1',
      capabilities: ['os:mac', 'engine:devin'],
    });

    // stored doc holds a sha256 hash (64 hex), NOT the plaintext, and no plaintext field
    const stored = await repo.getFactoryToken(res.tokenId, PID);
    expect(stored).not.toBeNull();
    expect(stored?.tokenHash).toMatch(/^[0-9a-f]{64}$/);
    expect(stored?.tokenHash).not.toBe(res.token);
    expect(stored?.capabilities).toEqual(['os:mac', 'engine:devin']);
    expect(JSON.stringify(stored)).not.toContain(res.token);
    // the factory doc was created/linked
    expect((await repo.getFactory('fac_1', PID))?.factoryId).toBe('fac_1');
  });

  it('verifyToken: valid → scope; tampered/unknown → null', async () => {
    const res = await enrollment.enrollFactory({
      productId: PID,
      factoryId: 'fac_1',
      capabilities: ['os:mac'],
    });
    const ok = await enrollment.verifyToken(res.token);
    expect(ok).toMatchObject({
      factoryId: 'fac_1',
      productId: PID,
      capabilities: ['os:mac'],
      status: 'active',
    });
    expect(await enrollment.verifyToken(`${res.token}x`)).toBeNull();
    expect(await enrollment.verifyToken('flt_unknown')).toBeNull();
    expect(await enrollment.verifyToken(undefined)).toBeNull();
  });

  it('revoke: token is immediately rejected', async () => {
    const res = await enrollment.enrollFactory({
      productId: PID,
      factoryId: 'fac_1',
      capabilities: [],
    });
    expect(await enrollment.verifyToken(res.token)).not.toBeNull();
    const n = await enrollment.revokeToken({ productId: PID, factoryId: 'fac_1' });
    expect(n).toBe(1);
    expect(await enrollment.verifyToken(res.token)).toBeNull();
  });

  it('rotate: old token works during the grace overlap, then is rejected; new token works', async () => {
    const first = await enrollment.enrollFactory({
      productId: PID,
      factoryId: 'fac_1',
      capabilities: ['os:mac'],
    });
    const second = await enrollment.rotateToken({ productId: PID, factoryId: 'fac_1' });

    // both valid during the overlap; new token inherits the prior scope
    expect(await enrollment.verifyToken(first.token)).toMatchObject({ status: 'rotating' });
    expect(await enrollment.verifyToken(second.token)).toMatchObject({
      status: 'active',
      capabilities: ['os:mac'],
    });

    // once the grace window has elapsed, the old token is rejected
    await repo.updateFactoryToken(first.tokenId, PID, {
      rotatingUntil: new Date(Date.now() - 1000).toISOString(),
    });
    expect(await enrollment.verifyToken(first.token)).toBeNull();
    expect(await enrollment.verifyToken(second.token)).not.toBeNull();
  });

  // ── Token expiry (§12 — bound a leaked credential) ──
  it('issues tokens with an expiresAt (default 90d) and rejects an expired one', async () => {
    const res = await enrollment.enrollFactory({ productId: PID, factoryId: 'fac_1' });
    const stored = await repo.getFactoryToken(res.tokenId, PID);
    expect(stored?.expiresAt).toBeTruthy();
    // valid now…
    expect(await enrollment.verifyToken(res.token)).not.toBeNull();
    // …but once expiresAt is in the past, verify rejects it (even though active).
    await repo.updateFactoryToken(res.tokenId, PID, {
      expiresAt: new Date(Date.now() - 1000).toISOString(),
    });
    expect(await enrollment.verifyToken(res.token)).toBeNull();
  });

  it('FLEET_TOKEN_TTL_DAYS=0 disables expiry (no expiresAt)', async () => {
    process.env.FLEET_TOKEN_TTL_DAYS = '0';
    const res = await enrollment.enrollFactory({ productId: PID, factoryId: 'fac_1' });
    const stored = await repo.getFactoryToken(res.tokenId, PID);
    expect(stored?.expiresAt).toBeUndefined();
    expect(await enrollment.verifyToken(res.token)).not.toBeNull();
  });

  // ── Enforcement default (production-on, dev/test-off) ──
  it('factoryTokenEnforcementEnabled: defaults on in production, off otherwise; explicit wins', () => {
    process.env.NODE_ENV = 'production';
    delete process.env.FLEET_REQUIRE_FACTORY_TOKEN;
    expect(enrollment.factoryTokenEnforcementEnabled()).toBe(true);
    process.env.NODE_ENV = 'development';
    expect(enrollment.factoryTokenEnforcementEnabled()).toBe(false);
    process.env.NODE_ENV = 'test';
    expect(enrollment.factoryTokenEnforcementEnabled()).toBe(false);
    // explicit override beats the environment default either way
    process.env.NODE_ENV = 'development';
    process.env.FLEET_REQUIRE_FACTORY_TOKEN = '1';
    expect(enrollment.factoryTokenEnforcementEnabled()).toBe(true);
    process.env.NODE_ENV = 'production';
    process.env.FLEET_REQUIRE_FACTORY_TOKEN = 'off';
    expect(enrollment.factoryTokenEnforcementEnabled()).toBe(false);
  });

  // ── Token GC ──
  it('pruneInvalidatedTokens removes revoked/expired/rotating-expired, keeps active', async () => {
    const active = await enrollment.enrollFactory({ productId: PID, factoryId: 'fac_active' });
    const revoked = await enrollment.enrollFactory({ productId: PID, factoryId: 'fac_revoked' });
    await enrollment.revokeToken({ productId: PID, factoryId: 'fac_revoked' });
    const expired = await enrollment.enrollFactory({ productId: PID, factoryId: 'fac_expired' });
    await repo.updateFactoryToken(expired.tokenId, PID, {
      expiresAt: new Date(Date.now() - 1000).toISOString(),
    });
    const rotating = await enrollment.enrollFactory({ productId: PID, factoryId: 'fac_rot' });
    await repo.updateFactoryToken(rotating.tokenId, PID, {
      status: 'rotating',
      rotatingUntil: new Date(Date.now() - 1000).toISOString(),
    });

    const deleted = await enrollment.pruneInvalidatedTokens();
    expect(deleted).toBe(3);
    expect(await repo.getFactoryToken(active.tokenId, PID)).not.toBeNull();
    expect(await repo.getFactoryToken(revoked.tokenId, PID)).toBeNull();
    expect(await repo.getFactoryToken(expired.tokenId, PID)).toBeNull();
    expect(await repo.getFactoryToken(rotating.tokenId, PID)).toBeNull();
  });

  // ── Gated enforcement on heartbeat/claim ──
  it('enforcement OFF (default): claim + heartbeat work with NO token', async () => {
    const app = await buildApp();
    await app.inject({
      method: 'POST',
      url: '/api/fleet/jobs',
      payload: { idempotencyKey: 'k1', bodyMd: '# t' },
    });
    const claim = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    expect(claim.statusCode).toBe(200);
    expect(JSON.parse(claim.body).claimed).toBe(true);

    const hb = await app.inject({
      method: 'POST',
      url: '/api/fleet/factories/heartbeat',
      payload: { factoryId: 'fac_1' },
    });
    expect(hb.statusCode).toBe(200);
  });

  it('enforcement ON: no token → 401; out-of-scope productId/capability → 403; in-scope → ok + constrained', async () => {
    const app = await buildApp();
    const enrolled = await enrollment.enrollFactory({
      productId: PID,
      factoryId: 'fac_1',
      capabilities: ['os:mac'],
    });
    await app.inject({
      method: 'POST',
      url: '/api/fleet/jobs',
      payload: { idempotencyKey: 'k1', bodyMd: '# t' },
    });

    process.env.FLEET_REQUIRE_FACTORY_TOKEN = '1';

    // no token → 401
    const noTok = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    expect(noTok.statusCode).toBe(401);

    // out-of-scope productId (token is for lysnrai, request asks for 'other') → 403
    const badPid = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      headers: { 'x-factory-token': enrolled.token },
      payload: { factoryId: 'fac_1', productId: 'other', capabilities: [] },
    });
    expect(badPid.statusCode).toBe(403);

    // out-of-scope capability → 403
    const badCap = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      headers: { 'x-factory-token': enrolled.token },
      payload: { factoryId: 'fac_1', capabilities: ['os:linux'] },
    });
    expect(badCap.statusCode).toBe(403);

    // in-scope → ok (claim constrained to the token scope)
    const ok = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      headers: { 'x-factory-token': enrolled.token },
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    expect(ok.statusCode).toBe(200);
    expect(JSON.parse(ok.body).claimed).toBe(true);
  });

  it('enforcement ON: heartbeat advertises only enrolled caps (out-of-scope cap dropped)', async () => {
    const app = await buildApp();
    const enrolled = await enrollment.enrollFactory({
      productId: PID,
      factoryId: 'fac_1',
      capabilities: ['os:mac', 'engine:devin'],
    });
    process.env.FLEET_REQUIRE_FACTORY_TOKEN = '1';

    const hb = await app.inject({
      method: 'POST',
      url: '/api/fleet/factories/heartbeat',
      headers: { 'x-factory-token': enrolled.token },
      // claims engine:codex it was NOT enrolled for, and omits a granted cap
      payload: { factoryId: 'fac_1', capabilities: ['engine:devin', 'engine:codex'] },
    });
    expect(hb.statusCode).toBe(200);
    const caps = JSON.parse(hb.body).factory.capabilities as string[];
    expect(caps).toContain('engine:devin'); // enrolled + reported → kept
    expect(caps).not.toContain('engine:codex'); // not enrolled → dropped
    expect(caps).not.toContain('os:mac'); // enrolled but not reported → not invented
  });

  it('enforcement OFF: heartbeat advertises self-reported caps verbatim', async () => {
    const app = await buildApp();
    const hb = await app.inject({
      method: 'POST',
      url: '/api/fleet/factories/heartbeat',
      payload: { factoryId: 'fac_1', capabilities: ['engine:codex'] },
    });
    expect(hb.statusCode).toBe(200);
    expect(JSON.parse(hb.body).factory.capabilities).toEqual(['engine:codex']);
  });

  it('enforcement ON: revoked token → 401 on heartbeat', async () => {
    const app = await buildApp();
    const enrolled = await enrollment.enrollFactory({
      productId: PID,
      factoryId: 'fac_1',
      capabilities: [],
    });
    await enrollment.revokeToken({ productId: PID, factoryId: 'fac_1' });
    process.env.FLEET_REQUIRE_FACTORY_TOKEN = '1';
    const hb = await app.inject({
      method: 'POST',
      url: '/api/fleet/factories/heartbeat',
      headers: { 'x-factory-token': enrolled.token },
      payload: { factoryId: 'fac_1' },
    });
    expect(hb.statusCode).toBe(401);
  });

  // ── Management routes ──
  it('POST /fleet/factories/enroll returns a token once (201) and rotate/revoke work', async () => {
    const app = await buildApp();
    const enroll = await app.inject({
      method: 'POST',
      url: '/api/fleet/factories/enroll',
      payload: { factoryId: 'fac_1', capabilities: ['os:mac'] },
    });
    expect(enroll.statusCode).toBe(201);
    const body = JSON.parse(enroll.body);
    expect(body.token).toMatch(/^flt_/);
    expect(body.scope.capabilities).toEqual(['os:mac']);

    const rotate = await app.inject({
      method: 'POST',
      url: '/api/fleet/factories/fac_1/token/rotate',
      payload: {},
    });
    expect(rotate.statusCode).toBe(200);
    expect(JSON.parse(rotate.body).token).toMatch(/^flt_/);
    expect(JSON.parse(rotate.body).token).not.toBe(body.token);

    const revoke = await app.inject({
      method: 'POST',
      url: '/api/fleet/factories/fac_1/token/revoke',
      payload: {},
    });
    expect(revoke.statusCode).toBe(200);
    expect(JSON.parse(revoke.body).revoked).toBeGreaterThanOrEqual(1);
  });

  // ── Artifact review fixes ──
  it('artifacts: list is productId-scoped (a foreign product sees none)', async () => {
    const now = new Date().toISOString();
    await repo.createArtifact({
      id: 'art_mine',
      productId: PID,
      jobId: 'jX',
      kind: 'log',
      blobKey: 'fleet/lysnrai/jX/art_mine-log',
      contentType: 'text/plain',
      sizeBytes: 3,
      createdAt: now,
    });
    await repo.createArtifact({
      id: 'art_theirs',
      productId: 'other-product',
      jobId: 'jX',
      kind: 'log',
      blobKey: 'fleet/other/jX/art_theirs-log',
      contentType: 'text/plain',
      sizeBytes: 3,
      createdAt: now,
    });
    const mine = await repo.listArtifactsByJob('jX', PID);
    expect(mine).toHaveLength(1);
    expect(mine[0].id).toBe('art_mine');
    expect(await repo.listArtifactsByJob('jX', 'other-product')).toHaveLength(1);
    expect(await repo.listArtifactsByJob('jX', 'nobody')).toHaveLength(0);
  });

  it('artifacts: upload ignores a spoofed body.productId (uses the request product)', async () => {
    const app = await buildApp();
    const sub = await app.inject({
      method: 'POST',
      url: '/api/fleet/jobs',
      payload: { idempotencyKey: 'k1', bodyMd: '# t' },
    });
    const jobId = JSON.parse(sub.body).job.id as string;
    const up = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${jobId}/artifacts`,
      payload: {
        productId: 'attacker-product',
        kind: 'log',
        contentType: 'text/plain',
        contentBase64: Buffer.from('hello').toString('base64'),
      },
    });
    expect(up.statusCode).toBe(201);
    // the pointer is stored under the REQUEST product (lysnrai), not the spoofed one
    expect(JSON.parse(up.body).artifact.productId).toBe(PID);
    expect(await repo.listArtifactsByJob(jobId, PID)).toHaveLength(1);
    expect(await repo.listArtifactsByJob(jobId, 'attacker-product')).toHaveLength(0);
  });
});
