/**
 * Full-bundle backup export (Phase 4a — docs/storage-migration-and-backup-plan.md).
 *
 * Today's `runBackup()` (backup.service.ts) dumps ONLY the database, buffers the
 * whole dump in memory, and writes it back to the SAME storage backend it would
 * lose if storage died. This module produces a *complete*, backend-agnostic
 * disaster-recovery bundle:
 *
 *   bundle.tar
 *   ├── db.dump            (pg_dump --format=custom of the live DB)
 *   ├── blobs/<storage_key>  (every blob referenced by a DB row)
 *   └── manifest.json       (sha256 + size per object, for restore-side verify)
 *
 * Streaming is mandatory: blobs are piped backend → tar with a known size
 * (`stats.size`, which is what makes archiver stream instead of buffering the
 * whole entry into memory) so total memory stays O(largest chunk), not
 * O(total bytes). The tar is assembled to a temp file first, then handed to the
 * caller to stream to the operator — so a mid-assembly failure surfaces as a
 * clean error rather than a truncated download.
 *
 * The assembler (`assembleBackupTar`) is pure w.r.t. its inputs (a storage
 * backend, a pre-produced dump file, a blob-ref list) so it is unit-tested with
 * an in-memory backend; the orchestrator (`createFullBackupTar`) wires in
 * pg_dump + the live blob inventory.
 */

import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { Transform } from 'node:stream';

import archiver from 'archiver';

import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { getStorageBackend, type StorageBackend } from '@/lib/storage';
import { collectStorageRefs } from '@/lib/storage/migrate';
import { runPgDump } from '@/lib/services/backup.service';

/** A blob the bundle should attempt to include. */
export interface BackupBlobRef {
  tableName: string;
  pk: string;
  key: string;
}

export interface BackupManifestBlobEntry {
  table: string;
  pk: string;
  key: string;
  sizeBytes: number;
  sha256: string;
}

export interface BackupSkippedEntry {
  table: string;
  pk: string;
  key: string;
  reason: string;
}

export interface BackupManifest {
  formatVersion: number;
  createdAt: string;
  storageBackend: string;
  database: {
    file: string;
    format: string;
    sizeBytes: number;
    sha256: string;
  };
  blobs: BackupManifestBlobEntry[];
  skipped: BackupSkippedEntry[];
  counts: {
    blobs: number;
    blobBytes: number;
    skipped: number;
  };
}

/**
 * Pipe `source` into the archive under `name`, computing the sha256 and byte
 * count of exactly the bytes that pass through. Resolves once the entry has
 * been fully consumed by archiver.
 *
 * `stats.size` MUST be supplied: archiver's tar plugin streams the entry only
 * when `data.stats` is present (otherwise it buffers the whole stream into
 * memory via `collectStream` to discover the size — the exact behaviour we're
 * avoiding for multi-GB blob sets).
 */
function appendHashedStream(
  archive: archiver.Archiver,
  source: NodeJS.ReadableStream,
  name: string,
  declaredSize: number,
  now: Date,
): Promise<{ sha256: string; bytes: number }> {
  const hash = createHash('sha256');
  let bytes = 0;
  const tee = new Transform({
    transform(chunk: Buffer, _enc, cb) {
      hash.update(chunk);
      bytes += chunk.length;
      cb(null, chunk);
    },
  });

  const done = new Promise<{ sha256: string; bytes: number }>((resolve, reject) => {
    source.on('error', (err) => tee.destroy(err instanceof Error ? err : new Error(String(err))));
    tee.on('error', reject);
    tee.on('end', () => resolve({ sha256: hash.digest('hex'), bytes }));
  });

  source.pipe(tee);
  archive.append(tee, {
    name,
    date: now,
    // A minimal fs.Stats-like object. `size` engages archiver's streaming
    // tar path; `mode`/`mtime` keep the header deterministic.
    stats: {
      size: declaredSize,
      mode: 0o644,
      mtime: now,
      isFile: () => true,
      isDirectory: () => false,
    } as unknown as import('node:fs').Stats,
  });

  return done;
}

/**
 * Assemble a backup tar at `outFilePath` from a pre-produced pg_dump file and a
 * list of blob references. Returns the manifest describing the bundle.
 */
export async function assembleBackupTar(opts: {
  backend: StorageBackend;
  dumpFilePath: string;
  blobRefs: BackupBlobRef[];
  outFilePath: string;
  storageBackendName: string;
  now: Date;
}): Promise<BackupManifest> {
  const { backend, dumpFilePath, blobRefs, outFilePath, storageBackendName, now } = opts;

  const archive = archiver('tar');
  const output = createWriteStream(outFilePath);

  const finished = new Promise<void>((resolve, reject) => {
    output.on('close', () => resolve());
    output.on('error', reject);
    archive.on('error', reject);
    archive.on('warning', (err: Error & { code?: string }) => {
      // Non-fatal (e.g. ENOENT on a vanished file) — log and keep going.
      logger.warn({ err }, 'archiver warning during backup export');
    });
  });

  archive.pipe(output);

  // 1. db.dump
  const dumpStat = await stat(dumpFilePath);
  const dump = await appendHashedStream(
    archive,
    createReadStream(dumpFilePath),
    'db.dump',
    dumpStat.size,
    now,
  );

  // 2. blobs (one at a time so memory stays bounded)
  const blobs: BackupManifestBlobEntry[] = [];
  const skipped: BackupSkippedEntry[] = [];
  for (const ref of blobRefs) {
    const head = await backend.head(ref.key);
    if (!head) {
      skipped.push({
        table: ref.tableName,
        pk: ref.pk,
        key: ref.key,
        reason: 'missing-in-storage',
      });
      continue;
    }
    let source: NodeJS.ReadableStream;
    try {
      source = await backend.get(ref.key);
    } catch (err) {
      skipped.push({
        table: ref.tableName,
        pk: ref.pk,
        key: ref.key,
        reason: `unreadable: ${err instanceof Error ? err.message : 'unknown'}`,
      });
      continue;
    }
    const { sha256, bytes } = await appendHashedStream(
      archive,
      source,
      `blobs/${ref.key}`,
      head.sizeBytes,
      now,
    );
    blobs.push({ table: ref.tableName, pk: ref.pk, key: ref.key, sizeBytes: bytes, sha256 });
  }

  // 3. manifest.json (last — it carries the sha256 of every prior entry)
  const manifest: BackupManifest = {
    formatVersion: 1,
    createdAt: now.toISOString(),
    storageBackend: storageBackendName,
    database: {
      file: 'db.dump',
      format: 'pg_dump-custom',
      sizeBytes: dump.bytes,
      sha256: dump.sha256,
    },
    blobs,
    skipped,
    counts: {
      blobs: blobs.length,
      blobBytes: blobs.reduce((acc, b) => acc + b.sizeBytes, 0),
      skipped: skipped.length,
    },
  };
  archive.append(Buffer.from(JSON.stringify(manifest, null, 2)), {
    name: 'manifest.json',
    date: now,
  });

  await archive.finalize();
  await finished;
  return manifest;
}

export interface FullBackupResult {
  tarPath: string;
  filename: string;
  manifest: BackupManifest;
  /** Removes the assembled tar. The intermediate dump is removed eagerly. */
  cleanup: () => Promise<void>;
}

/**
 * Orchestrate a full backup: pg_dump the live DB, inventory every blob, and
 * assemble the bundle to a temp tar. The caller streams `tarPath` to the
 * operator and invokes `cleanup()` when the download finishes.
 *
 * `backup_jobs` blobs (prior pg_dump artefacts) are excluded so a full export
 * doesn't recursively bundle previous backups.
 */
export async function createFullBackupTar(): Promise<FullBackupResult> {
  const now = new Date();
  const id = crypto.randomUUID();
  const dumpPath = path.join(tmpdir(), `pn-fullbackup-${id}.dump`);
  const tarPath = path.join(tmpdir(), `pn-fullbackup-${id}.tar`);

  try {
    await runPgDump(env.DATABASE_URL, dumpPath);
    const backend = await getStorageBackend();
    const refs = await collectStorageRefs({ excludeTables: ['backup_jobs'] });
    const blobRefs: BackupBlobRef[] = refs.map((r) => ({
      tableName: r.tableName,
      pk: r.pk,
      key: r.key,
    }));

    const manifest = await assembleBackupTar({
      backend,
      dumpFilePath: dumpPath,
      blobRefs,
      outFilePath: tarPath,
      storageBackendName: backend.name,
      now,
    });

    logger.info(
      { blobs: manifest.counts.blobs, skipped: manifest.counts.skipped },
      'Full backup bundle assembled',
    );

    const stamp = now.toISOString().replace(/[:.]/g, '-');
    return {
      tarPath,
      filename: `pn-crm-backup-${stamp}.tar`,
      manifest,
      cleanup: async () => {
        await unlink(tarPath).catch(() => {});
      },
    };
  } finally {
    // The dump is already inside the tar (or assembly failed) — drop it now.
    await unlink(dumpPath).catch(() => {});
  }
}
