/**
 * Unit test for the full-bundle backup tar assembler
 * (`assembleBackupTar` in `src/lib/services/backup-export.service.ts`).
 *
 * Phase 4a of docs/storage-migration-and-backup-plan.md: assemble a single
 * tar containing `db.dump` (a pre-produced pg_dump file) + `blobs/<key>` for
 * every blob-bearing row + a `manifest.json` describing the bundle with a
 * sha256 per object so a restore can verify integrity.
 *
 * Uses an in-memory storage backend (no MinIO) and a synthetic dump file
 * (no pg_dump). The produced tar is read back with the system `tar` CLI so we
 * assert against the real archive bytes, not archiver internals.
 */

import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { assembleBackupTar } from '@/lib/services/backup-export.service';
import type { PresignOpts, PutOpts, StorageBackend } from '@/lib/storage';

class InMemoryBackend implements StorageBackend {
  readonly name = 's3' as const;
  readonly store = new Map<string, { body: Buffer; contentType: string }>();

  async put(
    key: string,
    body: Buffer | NodeJS.ReadableStream,
    opts: PutOpts,
  ): Promise<{ key: string; sizeBytes: number; sha256: string }> {
    const buffer = Buffer.isBuffer(body) ? body : await streamToBuffer(body);
    this.store.set(key, { body: buffer, contentType: opts.contentType });
    return {
      key,
      sizeBytes: buffer.length,
      sha256: createHash('sha256').update(buffer).digest('hex'),
    };
  }

  async get(key: string): Promise<NodeJS.ReadableStream> {
    const r = this.store.get(key);
    if (!r) throw new Error(`not found: ${key}`);
    // Chunk the body so multi-chunk streaming is exercised.
    const chunks: Buffer[] = [];
    for (let i = 0; i < r.body.length; i += 64 * 1024) {
      chunks.push(r.body.subarray(i, i + 64 * 1024));
    }
    return Readable.from(chunks.length ? chunks : [Buffer.alloc(0)]);
  }

  async head(key: string) {
    const r = this.store.get(key);
    if (!r) return null;
    return { sizeBytes: r.body.length, contentType: r.contentType };
  }

  async delete(key: string): Promise<void> {
    this.store.delete(key);
  }

  async listByPrefix(prefix: string): Promise<string[]> {
    return [...this.store.keys()].filter((k) => k.startsWith(prefix));
  }

  async presignUpload(_key: string, _opts: PresignOpts) {
    return { url: 'mem://upload', method: 'PUT' as const };
  }

  async presignDownload(_key: string, _opts: PresignOpts) {
    return { url: 'mem://download', expiresAt: new Date(Date.now() + 1000) };
  }
}

async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string));
  return Buffer.concat(chunks);
}

function sha256(buf: Buffer): string {
  return createHash('sha256').update(buf).digest('hex');
}

describe('assembleBackupTar', () => {
  let workDir: string;

  beforeEach(() => {
    workDir = mkdtempSync(path.join(tmpdir(), 'pn-backup-test-'));
  });

  afterEach(() => {
    rmSync(workDir, { recursive: true, force: true });
  });

  it('bundles the db dump + every blob with a verifiable manifest', async () => {
    const backend = new InMemoryBackend();

    // A small blob and a larger multi-chunk blob (exercises streaming).
    const smallBody = Buffer.from('the quick brown fox');
    const bigBody = Buffer.alloc(300 * 1024);
    for (let i = 0; i < bigBody.length; i++) bigBody[i] = (i * 7) % 256;
    await backend.put('port-nimara/files/a.bin', smallBody, { contentType: 'application/pdf' });
    await backend.put('port-nimara/berths/big.pdf', bigBody, { contentType: 'application/pdf' });

    // Synthetic pg_dump file.
    const dumpBody = Buffer.from('PGDMP-FAKE-CUSTOM-FORMAT-CONTENTS\n'.repeat(1000));
    const dumpPath = path.join(workDir, 'db.dump');
    writeFileSync(dumpPath, dumpBody);

    const outPath = path.join(workDir, 'bundle.tar');
    const now = new Date('2026-06-04T12:00:00.000Z');

    const manifest = await assembleBackupTar({
      backend,
      dumpFilePath: dumpPath,
      blobRefs: [
        { tableName: 'files', pk: 'f1', key: 'port-nimara/files/a.bin' },
        { tableName: 'berth_pdf_versions', pk: 'b1', key: 'port-nimara/berths/big.pdf' },
      ],
      outFilePath: outPath,
      storageBackendName: 's3',
      now,
    });

    // ── manifest shape ──────────────────────────────────────────────────────
    expect(manifest.formatVersion).toBe(1);
    expect(manifest.createdAt).toBe('2026-06-04T12:00:00.000Z');
    expect(manifest.storageBackend).toBe('s3');
    expect(manifest.database.file).toBe('db.dump');
    expect(manifest.database.sizeBytes).toBe(dumpBody.length);
    expect(manifest.database.sha256).toBe(sha256(dumpBody));
    expect(manifest.counts.blobs).toBe(2);
    expect(manifest.counts.blobBytes).toBe(smallBody.length + bigBody.length);
    expect(manifest.counts.skipped).toBe(0);

    const smallEntry = manifest.blobs.find((b) => b.key === 'port-nimara/files/a.bin');
    expect(smallEntry).toMatchObject({
      table: 'files',
      pk: 'f1',
      sizeBytes: smallBody.length,
      sha256: sha256(smallBody),
    });

    // ── extract the real tar and verify bytes ────────────────────────────────
    const extractDir = path.join(workDir, 'extract');
    execFileSync('mkdir', ['-p', extractDir]);
    execFileSync('tar', ['-xf', outPath, '-C', extractDir]);

    const extractedDump = readFileSync(path.join(extractDir, 'db.dump'));
    expect(extractedDump.equals(dumpBody)).toBe(true);

    const extractedSmall = readFileSync(path.join(extractDir, 'blobs/port-nimara/files/a.bin'));
    expect(extractedSmall.equals(smallBody)).toBe(true);

    const extractedBig = readFileSync(path.join(extractDir, 'blobs/port-nimara/berths/big.pdf'));
    expect(extractedBig.equals(bigBody)).toBe(true);

    // The on-disk manifest matches the returned one and verifies the bytes.
    const onDiskManifest = JSON.parse(readFileSync(path.join(extractDir, 'manifest.json'), 'utf8'));
    expect(onDiskManifest).toEqual(manifest);
    for (const entry of manifest.blobs) {
      const bytes = readFileSync(path.join(extractDir, 'blobs', entry.key));
      expect(sha256(bytes)).toBe(entry.sha256);
      expect(bytes.length).toBe(entry.sizeBytes);
    }
  });

  it('records referenced-but-missing blobs as skipped instead of failing', async () => {
    const backend = new InMemoryBackend();
    await backend.put('port-nimara/files/present.bin', Buffer.from('here'), {
      contentType: 'application/octet-stream',
    });

    const dumpPath = path.join(workDir, 'db.dump');
    writeFileSync(dumpPath, Buffer.from('dump'));
    const outPath = path.join(workDir, 'bundle.tar');

    const manifest = await assembleBackupTar({
      backend,
      dumpFilePath: dumpPath,
      blobRefs: [
        { tableName: 'files', pk: 'f1', key: 'port-nimara/files/present.bin' },
        { tableName: 'files', pk: 'f2', key: 'port-nimara/files/GONE.bin' },
      ],
      outFilePath: outPath,
      storageBackendName: 's3',
      now: new Date('2026-06-04T12:00:00.000Z'),
    });

    expect(manifest.counts.blobs).toBe(1);
    expect(manifest.counts.skipped).toBe(1);
    expect(manifest.skipped).toEqual([
      expect.objectContaining({ table: 'files', pk: 'f2', key: 'port-nimara/files/GONE.bin' }),
    ]);

    // The missing blob must NOT appear in the archive.
    const extractDir = path.join(workDir, 'extract');
    execFileSync('mkdir', ['-p', extractDir]);
    execFileSync('tar', ['-xf', outPath, '-C', extractDir]);
    const blobFiles = readdirSync(path.join(extractDir, 'blobs', 'port-nimara', 'files'));
    expect(blobFiles).toEqual(['present.bin']);
  });
});
