/**
 * In-app backup orchestration. Drives the `/admin/backup` page.
 *
 * Each `runBackup()` invocation:
 *   1. Inserts a `backup_jobs` row in status='running'
 *   2. Spawns `pg_dump` against the DATABASE_URL into a temp file
 *   3. Streams the dump to the active storage backend at
 *      `backups/<id>.dump` (works for both S3 and filesystem)
 *   4. Marks the row completed/failed + records size + storage_path
 *
 * Restore is intentionally NOT exposed via the in-app UI yet - that
 * needs a 2-step confirm + a maintenance window since it requires
 * dropping the existing schema. Provide a CLI helper later via a
 * downloadable .dump from the admin page (already wired below).
 */
import { spawn } from 'node:child_process';
import { createReadStream, createWriteStream } from 'node:fs';
import { unlink, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { eq } from 'drizzle-orm';

import { db } from '@/lib/db';
import { backupJobs } from '@/lib/db/schema/system';
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';

interface RunBackupArgs {
  trigger: 'manual' | 'cron';
  triggeredBy?: string | null;
}

export async function runBackup({ trigger, triggeredBy }: RunBackupArgs): Promise<{
  id: string;
  status: 'completed' | 'failed';
  sizeBytes?: number;
  error?: string;
}> {
  const [row] = await db
    .insert(backupJobs)
    .values({ status: 'running', trigger, triggeredBy: triggeredBy ?? null })
    .returning();
  if (!row) throw new Error('Failed to create backup_jobs row');

  const tmpFile = path.join(tmpdir(), `pn-backup-${row.id}.dump`);
  let sizeBytes: number | undefined;

  try {
    await runPgDump(env.DATABASE_URL, tmpFile);
    const s = await stat(tmpFile);
    sizeBytes = s.size;

    const storagePath = `backups/${row.id}.dump`;
    const backend = await getStorageBackend();
    const stream = createReadStream(tmpFile);
    // Buffer-up the file rather than streaming because the storage
    // abstraction's `put` takes a Buffer. For multi-GB dumps this
    // would need streaming support - flag in the comment.
    const chunks: Buffer[] = [];
    for await (const chunk of stream) chunks.push(chunk as Buffer);
    await backend.put(storagePath, Buffer.concat(chunks), {
      contentType: 'application/octet-stream',
      sizeBytes,
    });

    await db
      .update(backupJobs)
      .set({
        status: 'completed',
        sizeBytes,
        storagePath,
        completedAt: new Date(),
      })
      .where(eq(backupJobs.id, row.id));
    logger.info({ id: row.id, sizeBytes }, 'Backup completed');
    return { id: row.id, status: 'completed', sizeBytes };
  } catch (err) {
    const message = err instanceof Error ? err.message : 'unknown';
    await db
      .update(backupJobs)
      .set({ status: 'failed', errorMessage: message, completedAt: new Date() })
      .where(eq(backupJobs.id, row.id));
    logger.error({ id: row.id, err }, 'Backup failed');
    return { id: row.id, status: 'failed', error: message };
  } finally {
    void unlink(tmpFile).catch(() => {});
  }
}

export interface RunPgDumpOpts {
  /** Override the binary (tests inject `node`). Defaults to `pg_dump`. */
  command?: string;
  /** Build the argv from the connection URL. Defaults to a custom-format dump. */
  buildArgs?: (databaseUrl: string) => string[];
}

export function runPgDump(
  databaseUrl: string,
  outFile: string,
  opts: RunPgDumpOpts = {},
): Promise<void> {
  const command = opts.command ?? 'pg_dump';
  const args = (opts.buildArgs ?? ((url) => ['--format=custom', '--no-owner', url]))(databaseUrl);

  return new Promise((resolve, reject) => {
    const child = spawn(command, args);
    const out = createWriteStream(outFile);
    // `stdout.pipe(out)` auto-ends `out` when the child's stdout closes, so the
    // file's `finish` event can fire *before* the process `close` event. Gate
    // resolution on BOTH having happened rather than attaching the `finish`
    // listener inside the `close` handler (which raced and hung when `finish`
    // had already fired).
    let stderr = '';
    let settled = false;
    let exitCode: number | null = null;
    let fileFlushed = false;

    const fail = (err: Error): void => {
      if (settled) return;
      settled = true;
      reject(err);
    };
    const maybeResolve = (): void => {
      if (settled || exitCode === null || !fileFlushed) return;
      if (exitCode === 0) {
        settled = true;
        resolve();
      } else {
        fail(new Error(`pg_dump exited ${exitCode}: ${stderr}`));
      }
    };

    child.stderr.on('data', (b) => {
      stderr += b.toString();
    });
    child.on('error', fail);
    out.on('error', fail);
    out.on('finish', () => {
      fileFlushed = true;
      maybeResolve();
    });
    child.on('close', (code) => {
      exitCode = code;
      maybeResolve();
    });
    child.stdout.pipe(out);
  });
}

export async function getBackupDownloadUrl(id: string): Promise<string | null> {
  const row = await db.query.backupJobs.findFirst({ where: eq(backupJobs.id, id) });
  if (!row || !row.storagePath || row.status !== 'completed') return null;
  return presignDownloadUrl(row.storagePath, 3600, `backup-${row.id}.dump`);
}

export async function listBackupJobs(limit = 50) {
  const rows = await db.query.backupJobs.findMany({
    orderBy: (j, { desc }) => [desc(j.startedAt)],
    limit,
  });
  return rows;
}
