/**
 * Fleet artifacts — blob wiring (§13/§26).
 *
 * Runs on the in-memory datastore + in-memory blob provider. The central
 * guarantee under test: artifact BYTES live in blob storage and only a POINTER
 * (blobKey + size/content-type/sha256) is persisted in Cosmos — never the bytes.
 *
 * Auth + productId resolution are mocked exactly as the items / fleet routes
 * tests do. The non-route service tests don't touch those mocks.
 */

// Select the in-memory blob provider before the storage singleton is created.
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 { _resetBlobClient, getBucket } from '@bytelyst/blob';
import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js';
import * as repo from './repository.js';
import * as artifactsBlob from './artifacts-blob.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';

/** Fields that would indicate bytes were (wrongly) inlined into the Cosmos doc. */
const INLINE_PAYLOAD_FIELDS = [
  'contentBase64',
  'payload',
  'bytes',
  'data',
  'content',
  'body',
  'blob',
];

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

beforeEach(() => {
  setProvider(new MemoryDatastoreProvider());
  _resetBlobClient();
});
afterEach(() => {
  _resetDatastoreProvider();
  _resetBlobClient();
  vi.clearAllMocks();
});

describe('fleet artifacts — blob service', () => {
  it('upload writes bytes to blob and persists a pointer-only Cosmos doc', async () => {
    const bytes = Buffer.from('hello log output');
    const { artifact, downloadUrl } = await artifactsBlob.uploadArtifact({
      productId: PID,
      jobId: 'j1',
      kind: 'log',
      bytes,
      contentType: 'text/plain',
    });

    // pointer metadata
    expect(artifact.productId).toBe(PID);
    expect(artifact.jobId).toBe('j1');
    expect(artifact.kind).toBe('log');
    expect(artifact.contentType).toBe('text/plain');
    expect(artifact.sizeBytes).toBe(bytes.length);
    expect(artifact.blobKey).toBe(`fleet/${PID}/j1/${artifact.id}-log`);
    expect(artifact.sha256).toMatch(/^[0-9a-f]{64}$/);
    expect(downloadUrl).toContain('signed=true');

    // the persisted Cosmos doc carries NO inline payload — bytes are not in Cosmos
    const stored = await repo.getArtifact(artifact.id, PID);
    expect(stored).not.toBeNull();
    for (const field of INLINE_PAYLOAD_FIELDS) {
      expect(stored).not.toHaveProperty(field);
    }
    // only the pointer/metadata fields are present
    expect(stored).toMatchObject({
      id: artifact.id,
      productId: PID,
      jobId: 'j1',
      kind: 'log',
      blobKey: artifact.blobKey,
      contentType: 'text/plain',
      sizeBytes: bytes.length,
    });

    // the actual bytes DO live in blob storage
    const bucket = await getBucket(artifactsBlob.FLEET_ARTIFACTS_CONTAINER);
    const fromBlob = await bucket.download(artifact.blobKey);
    expect(fromBlob.toString()).toBe('hello log output');
  });

  it('list by job is partition-isolated (returns only that job)', async () => {
    await artifactsBlob.uploadArtifact({
      productId: PID,
      jobId: 'jA',
      kind: 'log',
      bytes: Buffer.from('a1'),
      contentType: 'text/plain',
    });
    await artifactsBlob.uploadArtifact({
      productId: PID,
      jobId: 'jA',
      kind: 'coverage',
      bytes: Buffer.from('a2'),
      contentType: 'application/json',
    });
    await artifactsBlob.uploadArtifact({
      productId: PID,
      jobId: 'jB',
      kind: 'screenshot',
      bytes: Buffer.from('b1'),
      contentType: 'image/png',
    });

    const a = await repo.listArtifactsByJob('jA', PID);
    const b = await repo.listArtifactsByJob('jB', PID);
    expect(a).toHaveLength(2);
    expect(a.every(x => x.jobId === 'jA')).toBe(true);
    expect(b).toHaveLength(1);
    expect(b[0].jobId).toBe('jB');
  });

  it('get re-issues a fresh SAS URL; a large (>Cosmos-safe) payload still succeeds (blob offload)', async () => {
    // 3 MB — comfortably beyond the ~2 MB Cosmos document ceiling, so this can
    // only succeed because the bytes are offloaded to blob, not stored inline.
    const big = Buffer.alloc(3 * 1024 * 1024, 0x61);
    const { artifact } = await artifactsBlob.uploadArtifact({
      productId: PID,
      jobId: 'jbig',
      kind: 'build',
      bytes: big,
      contentType: 'application/octet-stream',
    });
    expect(artifact.sizeBytes).toBe(3 * 1024 * 1024);

    const dl = await artifactsBlob.getArtifactDownload(artifact.id, PID);
    expect(dl).not.toBeNull();
    expect(dl?.downloadUrl).toContain('signed=true');
    expect(dl?.downloadUrl).toContain(artifact.blobKey);

    // product-scoped: a foreign product cannot fetch it
    expect(await artifactsBlob.getArtifactDownload(artifact.id, 'other-product')).toBeNull();
    // bytes round-trip from blob at full size
    const bucket = await getBucket(artifactsBlob.FLEET_ARTIFACTS_CONTAINER);
    expect((await bucket.download(artifact.blobKey)).length).toBe(3 * 1024 * 1024);
  });

  it('delete removes both the pointer and the backing blob', async () => {
    const { artifact } = await artifactsBlob.uploadArtifact({
      productId: PID,
      jobId: 'jd',
      kind: 'other',
      bytes: Buffer.from('tmp'),
      contentType: 'text/plain',
    });
    const bucket = await getBucket(artifactsBlob.FLEET_ARTIFACTS_CONTAINER);
    expect(await bucket.exists(artifact.blobKey)).toBe(true);

    expect(await artifactsBlob.deleteArtifact(artifact.id, PID)).toBe(true);
    expect(await repo.getArtifact(artifact.id, PID)).toBeNull();
    expect(await bucket.exists(artifact.blobKey)).toBe(false);

    // idempotent / unknown → false
    expect(await artifactsBlob.deleteArtifact(artifact.id, PID)).toBe(false);
  });
});

describe('fleet artifacts — routes (fastify inject)', () => {
  const b64 = (s: string) => Buffer.from(s).toString('base64');

  it('POST upload → 201 with pointer + SAS; GET list → the pointer; bytes not echoed', async () => {
    const app = await buildApp();
    const res = await app.inject({
      method: 'POST',
      url: '/api/fleet/jobs/jr1/artifacts',
      payload: { kind: 'log', contentType: 'text/plain', contentBase64: b64('route log bytes') },
    });
    expect(res.statusCode).toBe(201);
    const body = JSON.parse(res.body);
    expect(body.artifact.jobId).toBe('jr1');
    expect(body.artifact.blobKey).toBe(`fleet/${PID}/jr1/${body.artifact.id}-log`);
    expect(body.artifact.sizeBytes).toBe(Buffer.from('route log bytes').length);
    expect(body.downloadUrl).toContain('signed=true');
    for (const field of INLINE_PAYLOAD_FIELDS) {
      expect(body.artifact).not.toHaveProperty(field);
    }

    const list = await app.inject({ method: 'GET', url: '/api/fleet/jobs/jr1/artifacts' });
    expect(list.statusCode).toBe(200);
    const arts = JSON.parse(list.body).artifacts;
    expect(arts).toHaveLength(1);
    expect(arts[0].id).toBe(body.artifact.id);
  });

  it('GET /fleet/artifacts/:id returns a fresh SAS URL; DELETE removes it', async () => {
    const app = await buildApp();
    const up = await app.inject({
      method: 'POST',
      url: '/api/fleet/jobs/jr2/artifacts',
      payload: {
        kind: 'coverage',
        contentType: 'application/json',
        contentBase64: b64('{"pct":91}'),
      },
    });
    const id = JSON.parse(up.body).artifact.id as string;

    const got = await app.inject({ method: 'GET', url: `/api/fleet/artifacts/${id}` });
    expect(got.statusCode).toBe(200);
    const gotBody = JSON.parse(got.body);
    expect(gotBody.artifact.id).toBe(id);
    expect(gotBody.downloadUrl).toContain('signed=true');

    const del = await app.inject({ method: 'DELETE', url: `/api/fleet/artifacts/${id}` });
    expect(del.statusCode).toBe(200);
    expect(JSON.parse(del.body).deleted).toBe(true);

    // gone now
    const after = await app.inject({ method: 'GET', url: `/api/fleet/artifacts/${id}` });
    expect(after.statusCode).toBe(404);
  });

  it('rejects an invalid upload body (400) and unknown artifact ids (404)', async () => {
    const app = await buildApp();

    const bad = await app.inject({
      method: 'POST',
      url: '/api/fleet/jobs/jr3/artifacts',
      payload: { contentBase64: b64('x') }, // missing kind
    });
    expect(bad.statusCode).toBe(400);

    const getMissing = await app.inject({ method: 'GET', url: '/api/fleet/artifacts/nope' });
    expect(getMissing.statusCode).toBe(404);

    const delMissing = await app.inject({ method: 'DELETE', url: '/api/fleet/artifacts/nope' });
    expect(delMissing.statusCode).toBe(404);
  });
});
