/**
 * SFTP/SSH backup transport — pushes the bundle to a remote server over SFTP.
 * This is the "separate server" destination most deployments will use.
 *
 * Host-key handling: when `hostFingerprint` is set, the server's key is verified
 * against it (sha256, colons/whitespace ignored) and the connection is rejected
 * on mismatch — defends against MITM. With no fingerprint configured we accept
 * on first use (TOFU); admins should pin the fingerprint for untrusted networks.
 */

import { createHash } from 'node:crypto';
import path from 'node:path';

import SftpClient from 'ssh2-sftp-client';

import { logger } from '@/lib/logger';

import {
  BACKUP_NAME_PREFIX,
  sortBundlesNewestFirst,
  type BackupTransport,
  type SftpDestConfig,
} from './types';

function normalizeFingerprint(fp: string): string {
  return fp
    .replace(/^sha256:/i, '')
    .replace(/[:\s]/g, '')
    .toLowerCase();
}

export class SftpTransport implements BackupTransport {
  constructor(private readonly cfg: SftpDestConfig) {}

  private connectOptions(): SftpClient.ConnectOptions {
    const expected = this.cfg.hostFingerprint
      ? normalizeFingerprint(this.cfg.hostFingerprint)
      : null;
    return {
      host: this.cfg.host,
      port: this.cfg.port ?? 22,
      username: this.cfg.username,
      ...(this.cfg.password ? { password: this.cfg.password } : {}),
      ...(this.cfg.privateKey ? { privateKey: this.cfg.privateKey } : {}),
      ...(this.cfg.passphrase ? { passphrase: this.cfg.passphrase } : {}),
      // ssh2 calls this with the server's host key; hash + compare to the pin.
      hostVerifier: (key: Buffer): boolean => {
        if (!expected) return true;
        const actual = createHash('sha256').update(key).digest('hex');
        const ok = actual === expected;
        if (!ok) logger.error({ host: this.cfg.host }, 'SFTP host-key fingerprint mismatch');
        return ok;
      },
    } as SftpClient.ConnectOptions;
  }

  private async withClient<T>(fn: (c: SftpClient) => Promise<T>): Promise<T> {
    const client = new SftpClient();
    try {
      await client.connect(this.connectOptions());
      return await fn(client);
    } finally {
      await client.end().catch(() => {});
    }
  }

  async test(): Promise<void> {
    await this.withClient(async (c) => {
      // Ensure the remote dir exists (create recursively if needed) and is usable.
      const exists = await c.exists(this.cfg.remoteDir);
      if (!exists) await c.mkdir(this.cfg.remoteDir, true);
    });
  }

  async push(localPath: string, remoteName: string): Promise<{ remoteRef: string; bytes: number }> {
    return this.withClient(async (c) => {
      const exists = await c.exists(this.cfg.remoteDir);
      if (!exists) await c.mkdir(this.cfg.remoteDir, true);
      const remotePath = path.posix.join(this.cfg.remoteDir, remoteName);
      await c.fastPut(localPath, remotePath);
      const s = await c.stat(remotePath);
      return { remoteRef: `sftp://${this.cfg.host}${remotePath}`, bytes: s.size };
    });
  }

  async prune(retentionCount: number | null): Promise<{ deleted: number }> {
    if (retentionCount === null || retentionCount < 0) return { deleted: 0 };
    return this.withClient(async (c) => {
      const list = await c.list(this.cfg.remoteDir);
      const bundles = sortBundlesNewestFirst(
        list
          .filter((e) => e.type === '-' && e.name.startsWith(BACKUP_NAME_PREFIX))
          .map((e) => e.name),
      );
      const toDelete = bundles.slice(retentionCount);
      for (const name of toDelete) {
        await c.delete(path.posix.join(this.cfg.remoteDir, name)).catch(() => {});
      }
      return { deleted: toDelete.length };
    });
  }
}
