/**
 * Initiative 4 (e2e testing) — storage backend swap.
 *
 * Exercises switching the deployment's storage backend between `s3` and
 * `filesystem` through the canonical `getStorageBackend()` abstraction
 * (`src/lib/storage/index.ts`), driven by the global
 * `system_settings.storage_backend` row (CLAUDE.md "Storage" section).
 *
 * Two concerns are covered:
 *
 *  1. Factory selection + cache invalidation — flipping the global setting
 *     between `filesystem` and `s3` makes `getStorageBackend()` resolve the
 *     matching backend, and the per-process fingerprint cache re-resolves on
 *     change (never returns a stale backend). The two concrete backends are
 *     mocked at the `S3Backend.create` / `FilesystemBackend.create` boundary
 *     so no MinIO/network is required — we're testing the *swap wiring*, not
 *     the S3 wire protocol (the remote MinIO endpoint is non-deterministic in
 *     this env).
 *
 *  2. Blob durability across a swap — a blob written under one backend stays
 *     resolvable from that backend after the global setting flips and back,
 *     mirroring a real migration window where the old store still answers
 *     reads. A second block round-trips a real on-disk blob through the
 *     genuine `FilesystemBackend` so the "written under one, resolvable"
 *     guarantee is proven against real I/O, not just the in-memory fake.
 *
 * Per the abstraction contract we NEVER import the S3 SDK directly.
 */

import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { Readable } from 'node:stream';

import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { and, eq, isNull } from 'drizzle-orm';

import type { PresignOpts, PutOpts, StorageBackend, StorageBackendName } from '@/lib/storage';

// ── In-memory fake backends, tagged by name. The factory under test picks
//    one based on the global `storage_backend` setting; we record which
//    `create` ran and keep the bytes so a post-swap read still resolves. ──
const blobsByBackend: Record<StorageBackendName, Map<string, Buffer>> = {
  s3: new Map(),
  filesystem: new Map(),
};
const createCalls: StorageBackendName[] = [];

function makeFake(name: StorageBackendName): StorageBackend {
  const store = blobsByBackend[name];
  return {
    name,
    async put(key: string, body: Buffer | NodeJS.ReadableStream, _opts: PutOpts) {
      const buf = Buffer.isBuffer(body) ? body : Buffer.alloc(0);
      store.set(key, buf);
      return { key, sizeBytes: buf.length, sha256: 'fake'.padEnd(64, '0') };
    },
    async get(key: string) {
      const buf = store.get(key);
      if (!buf) throw new Error(`not found: ${key}`);
      return Readable.from([buf]);
    },
    async head(key: string) {
      const buf = store.get(key);
      return buf ? { sizeBytes: buf.length, contentType: 'application/octet-stream' } : null;
    },
    async delete(key: string) {
      store.delete(key);
    },
    async presignUpload(_key: string, _opts: PresignOpts) {
      return { url: `mem://${name}/upload`, method: 'PUT' as const };
    },
    async presignDownload(_key: string, _opts: PresignOpts) {
      return { url: `mem://${name}/download`, expiresAt: new Date(Date.now() + 1000) };
    },
    async listByPrefix(prefix: string) {
      return [...store.keys()].filter((k) => k.startsWith(prefix));
    },
  };
}

// Replace the two concrete backend constructors with fakes. The real
// `getStorageBackend()` factory (selection + fingerprint cache) is left
// untouched and is the actual subject under test.
vi.mock('@/lib/storage/s3', () => ({
  S3Backend: {
    create: vi.fn(async () => {
      createCalls.push('s3');
      return makeFake('s3');
    }),
  },
}));
vi.mock('@/lib/storage/filesystem', async (importOriginal) => {
  // Keep the real path-validation + HMAC exports intact; only override the
  // backend constructor so the swap test stays network/disk-free here. The
  // genuine FilesystemBackend is exercised separately below via a direct
  // import in its own describe block (different module instance).
  const real = await importOriginal<typeof import('@/lib/storage/filesystem')>();
  return {
    ...real,
    FilesystemBackend: {
      create: vi.fn(async () => {
        createCalls.push('filesystem');
        return makeFake('filesystem');
      }),
    },
  };
});

// Imported AFTER the mocks so the factory binds to the fakes.
const { getStorageBackend, resetStorageBackendCache } = await import('@/lib/storage');
const { db } = await import('@/lib/db');
const { systemSettings } = await import('@/lib/db/schema/system');

const STORAGE_KEYS = ['storage_backend', 'storage_filesystem_root'] as const;

/** Snapshot of the pre-existing global storage rows so we can restore them. */
let snapshot: { key: string; value: unknown }[] = [];

async function setGlobal(key: string, value: unknown): Promise<void> {
  await db
    .delete(systemSettings)
    .where(and(eq(systemSettings.key, key), isNull(systemSettings.portId)));
  await db.insert(systemSettings).values({ key, value, portId: null });
  // The settings-write hook normally calls this; do it explicitly so the
  // factory re-resolves on the next getStorageBackend().
  resetStorageBackendCache();
}

beforeAll(async () => {
  snapshot = await db
    .select({ key: systemSettings.key, value: systemSettings.value })
    .from(systemSettings)
    .where(and(isNull(systemSettings.portId)));
  snapshot = snapshot.filter((r) => (STORAGE_KEYS as readonly string[]).includes(r.key));
});

afterEach(() => {
  vi.clearAllMocks();
  createCalls.length = 0;
});

afterAll(async () => {
  // Restore the global storage settings exactly as we found them.
  for (const key of STORAGE_KEYS) {
    await db
      .delete(systemSettings)
      .where(and(eq(systemSettings.key, key), isNull(systemSettings.portId)));
    const original = snapshot.find((r) => r.key === key);
    if (original) {
      await db.insert(systemSettings).values({ key, value: original.value, portId: null });
    }
  }
  resetStorageBackendCache();
  blobsByBackend.s3.clear();
  blobsByBackend.filesystem.clear();
});

describe('getStorageBackend · backend swap selection', () => {
  it('resolves filesystem then s3 as the global setting flips', async () => {
    await setGlobal('storage_backend', 'filesystem');
    const fs = await getStorageBackend();
    expect(fs.name).toBe('filesystem');

    await setGlobal('storage_backend', 's3');
    const s3 = await getStorageBackend();
    expect(s3.name).toBe('s3');

    // Both concrete constructors ran exactly once across the two resolutions.
    expect(createCalls.filter((c) => c === 'filesystem')).toHaveLength(1);
    expect(createCalls.filter((c) => c === 's3')).toHaveLength(1);
  });

  it('any non-"filesystem" value falls back to s3 (factory default)', async () => {
    await setGlobal('storage_backend', 'totally-unknown-value');
    const backend = await getStorageBackend();
    expect(backend.name).toBe('s3');
  });

  it('caches within a config and re-resolves only after a change', async () => {
    await setGlobal('storage_backend', 'filesystem');
    await getStorageBackend();
    await getStorageBackend();
    await getStorageBackend();
    // Cache hit: the constructor ran once despite three resolutions.
    expect(createCalls.filter((c) => c === 'filesystem')).toHaveLength(1);

    // Changing the setting (here: the fs root) invalidates the fingerprint.
    await setGlobal('storage_filesystem_root', '/tmp/pn-swap-test-root');
    await getStorageBackend();
    expect(createCalls.filter((c) => c === 'filesystem')).toHaveLength(2);
  });

  it('a blob written under one backend stays resolvable after a swap and back', async () => {
    // Write under filesystem.
    await setGlobal('storage_backend', 'filesystem');
    const fsBackend = await getStorageBackend();
    const key = 'test-port/swap/blob.bin';
    const payload = Buffer.from('swap-durability-payload');
    await fsBackend.put(key, payload, { contentType: 'application/octet-stream' });

    // Swap to s3 — the filesystem store still holds the bytes (old store keeps
    // answering reads during a migration window).
    await setGlobal('storage_backend', 's3');
    const s3Backend = await getStorageBackend();
    expect(s3Backend.name).toBe('s3');
    expect(blobsByBackend.s3.has(key)).toBe(false); // not migrated by a mere flip

    // Swap back to filesystem and confirm the original blob round-trips.
    await setGlobal('storage_backend', 'filesystem');
    const fsAgain = await getStorageBackend();
    const stream = await fsAgain.get(key);
    const chunks: Buffer[] = [];
    for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string));
    expect(Buffer.concat(chunks).equals(payload)).toBe(true);
  });
});

describe('FilesystemBackend · real on-disk round-trip (abstraction contract)', () => {
  it('writes and resolves a real blob via the genuine filesystem backend', async () => {
    // Use the REAL FilesystemBackend directly (separate module instance from
    // the mocked one above — vi.mock is scoped to the factory's import graph).
    // EMAIL_CREDENTIAL_KEY / BETTER_AUTH_SECRET are required by the key
    // validator at module load; the vitest env provides them.
    const { FilesystemBackend } = await vi.importActual<typeof import('@/lib/storage/filesystem')>(
      '@/lib/storage/filesystem',
    );

    const root = await mkdtemp(path.join(tmpdir(), 'pn-swap-fs-'));
    try {
      const backend = await FilesystemBackend.create({ root, proxyHmacSecretEncrypted: null });
      expect(backend.name).toBe('filesystem');

      const key = 'sub/dir/durable.bin';
      const payload = Buffer.from('real-on-disk-bytes');
      const put = await backend.put(key, payload, { contentType: 'application/octet-stream' });
      expect(put.sizeBytes).toBe(payload.length);
      expect(put.sha256).toMatch(/^[0-9a-f]{64}$/);

      const head = await backend.head(key);
      expect(head?.sizeBytes).toBe(payload.length);

      const stream = await backend.get(key);
      const chunks: Buffer[] = [];
      for await (const c of stream) chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c as string));
      expect(Buffer.concat(chunks).equals(payload)).toBe(true);
    } finally {
      await rm(root, { recursive: true, force: true });
    }
  });
});
