import { Prisma, BackupStatus, AuditAction, InstanceStatus } from '@prisma/client';
import fs from 'fs/promises';
import { createReadStream } from 'fs';
import path from 'path';
import crypto from 'crypto';
import { execFile as execFileCb } from 'child_process';
import { promisify } from 'util';
import { prisma } from '../lib/prisma';
import { env } from '../config/env';
import { AppError } from '../middleware/error-handler';
import { decryptJson } from '../utils/encryption';
import * as docker from './docker.service';
import { logger } from '../utils/logger';
import { getRemoteDriverForInstance } from './execution-driver';
const execFile = promisify(execFileCb);

/**
 * Validate that a path is within the allowed storage boundary.
 */
function assertPathWithinBoundary(filePath: string, boundary: string, label: string): void {
  const normalized = path.resolve(filePath);
  const normalizedBoundary = path.resolve(boundary);
  if (!normalized.startsWith(normalizedBoundary + path.sep)) {
    throw new AppError(403, `${label} path outside allowed directory`, 'FORBIDDEN');
  }
}

/**
 * Compute SHA-256 hash of a file by streaming its contents.
 */
async function fileHash(filePath: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const hash = crypto.createHash('sha256');
    const stream = createReadStream(filePath);
    stream.on('data', (chunk) => hash.update(chunk));
    stream.on('end', () => resolve(hash.digest('hex')));
    stream.on('error', reject);
  });
}

/**
 * Get file size in bytes.
 */
async function fileSize(filePath: string): Promise<number> {
  const stat = await fs.stat(filePath);
  return stat.size;
}

/**
 * Create a backup for a given instance.
 */
export async function createBackup(instanceId: string, userId?: string, ipAddress?: string) {
  const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
  if (!instance) {
    throw new AppError(404, 'Instance not found', 'NOT_FOUND');
  }

  if (instance.status !== InstanceStatus.RUNNING) {
    throw new AppError(400, `Cannot backup instance in ${instance.status} state`, 'INVALID_STATE');
  }

  // `isRegistered` + `isRemote` = a remote CCP-managed instance (agent on the
  // far side). `isRegistered` alone (without `isRemote`) would mean a local
  // host-managed instance that CCP doesn't own the compose files for — that
  // case we still can't back up.
  if (instance.isRegistered && !instance.isRemote) {
    throw new AppError(400, 'Backups not managed by CCP for registered instances', 'NOT_MANAGED');
  }

  // Create backup record
  const backup = await prisma.backup.create({
    data: {
      instanceId,
      status: BackupStatus.PENDING,
    },
  });

  // Run backup asynchronously
  performBackup(backup.id, instance, userId, ipAddress).catch((err) => {
    logger.error(`[backup] Backup ${backup.id} failed: ${(err as Error).message}`);
  });

  return backup;
}

type BackupInstance = {
  id: string;
  slug: string;
  basePath: string;
  composeProject: string;
  encryptedSecrets: string | null;
  isRemote: boolean;
  agentUrl: string | null;
};

async function performBackup(
  backupId: string,
  instance: BackupInstance,
  userId?: string,
  ipAddress?: string
) {
  if (instance.isRemote) {
    return performRemoteBackup(backupId, instance, userId, ipAddress);
  }
  return performLocalBackup(backupId, instance, userId, ipAddress);
}

async function performLocalBackup(
  backupId: string,
  instance: BackupInstance,
  userId?: string,
  ipAddress?: string
) {
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const backupDir = path.join(env.BACKUP_STORAGE_PATH, instance.slug, timestamp);

  try {
    // Update status to IN_PROGRESS
    await prisma.backup.update({
      where: { id: backupId },
      data: { status: BackupStatus.IN_PROGRESS },
    });

    // Ensure backup directory exists
    await fs.mkdir(backupDir, { recursive: true });

    const manifestFiles: Array<{ name: string; size: number; sha256: string }> = [];

    // 1. Dump PostgreSQL
    try {
      const secrets = instance.encryptedSecrets
        ? decryptJson<Record<string, string>>(instance.encryptedSecrets)
        : {} as Record<string, string>;
      const pgPassword = secrets.V2_POSTGRES_PASSWORD || secrets.postgresPassword || 'changemaker';

      // Use docker compose exec to run pg_dump inside the container
      // Pass PGPASSWORD via -e flag so pg_dump can authenticate
      const dumpOutput = await docker.composeExec(
        instance.basePath,
        instance.composeProject,
        'v2-postgres',
        `pg_dump -U changemaker -d changemaker`,
        300_000, // 5 min timeout for large DBs
        { PGPASSWORD: pgPassword }
      );

      const dumpPath = path.join(backupDir, 'v2-postgres.sql');
      await fs.writeFile(dumpPath, dumpOutput);

      // Gzip the dump (execFile avoids shell injection)
      await execFile('gzip', [dumpPath], { timeout: 120_000 });
      const gzPath = dumpPath + '.gz';

      manifestFiles.push({
        name: 'v2-postgres.sql.gz',
        size: await fileSize(gzPath),
        sha256: await fileHash(gzPath),
      });

      logger.info(`[backup] ${instance.slug}: PostgreSQL dump complete`);
    } catch (err) {
      logger.warn(`[backup] ${instance.slug}: PostgreSQL dump failed: ${(err as Error).message}`);
      // Continue with backup — mark the dump as failed in manifest
    }

    // 2. Archive uploads if they exist
    const uploadsDir = path.join(instance.basePath, 'uploads');
    try {
      await fs.access(uploadsDir);
      const uploadsArchive = path.join(backupDir, 'uploads.tar.gz');
      await execFile('tar', ['-czf', uploadsArchive, '-C', instance.basePath, 'uploads'], { timeout: 300_000 });

      manifestFiles.push({
        name: 'uploads.tar.gz',
        size: await fileSize(uploadsArchive),
        sha256: await fileHash(uploadsArchive),
      });

      logger.info(`[backup] ${instance.slug}: Uploads archive complete`);
    } catch {
      // No uploads directory or archive failed — skip
      logger.debug(`[backup] ${instance.slug}: No uploads directory or archive skipped`);
    }

    // 3. Generate manifest
    const manifest = {
      instanceId: instance.id,
      instanceSlug: instance.slug,
      timestamp: new Date().toISOString(),
      files: manifestFiles,
    };

    const manifestPath = path.join(backupDir, 'manifest.json');
    await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));

    // 4. Create final archive
    const archiveName = `backup-${instance.slug}-${timestamp}.tar.gz`;
    const archivePath = path.join(env.BACKUP_STORAGE_PATH, instance.slug, archiveName);

    await execFile('tar', ['-czf', archivePath, '-C', path.dirname(backupDir), path.basename(backupDir)], {
      timeout: 300_000,
    });

    const totalSize = await fileSize(archivePath);

    // Cleanup the temp directory
    await fs.rm(backupDir, { recursive: true, force: true });

    // Update backup record
    await prisma.backup.update({
      where: { id: backupId },
      data: {
        status: BackupStatus.COMPLETED,
        archivePath,
        sizeBytes: BigInt(totalSize),
        manifest: manifest as unknown as Prisma.InputJsonValue,
        completedAt: new Date(),
      },
    });

    // Audit log
    if (userId) {
      await prisma.auditLog.create({
        data: {
          userId,
          instanceId: instance.id,
          action: AuditAction.BACKUP_CREATE,
          details: { backupId, archiveName, sizeBytes: totalSize },
          ipAddress,
        },
      });
    }

    logger.info(`[backup] ${instance.slug}: Backup complete (${(totalSize / 1024 / 1024).toFixed(1)} MB)`);
  } catch (err) {
    // Update backup as failed
    await prisma.backup.update({
      where: { id: backupId },
      data: {
        status: BackupStatus.FAILED,
        errorMessage: (err as Error).message,
        completedAt: new Date(),
      },
    });

    // Cleanup temp directory on failure
    try {
      await fs.rm(backupDir, { recursive: true, force: true });
    } catch {
      // Ignore cleanup errors
    }

    throw err;
  }
}

/**
 * Run a backup on a remote agent and stream the resulting archive to CCP storage.
 *
 * Flow:
 *   1. Tell agent to run scripts/backup.sh → { backupId, sizeBytes, sha256, manifest }
 *   2. Stream archive from agent → $BACKUP_STORAGE_PATH/{slug}/backup-{slug}-{backupId}.tar.gz
 *   3. Verify local SHA256 matches what the agent reported (defense in depth)
 *   4. Tell agent to delete its local copy (reclaim remote disk)
 *   5. Update Backup row as COMPLETED
 *
 * On failure at any step after the remote backup was created, we leave the
 * agent-side archive in place so the operator can retry the download.
 */
async function performRemoteBackup(
  backupId: string,
  instance: BackupInstance,
  userId?: string,
  ipAddress?: string
) {
  let archivePath: string | null = null;
  let agentBackupId: string | null = null;

  try {
    await prisma.backup.update({
      where: { id: backupId },
      data: { status: BackupStatus.IN_PROGRESS },
    });

    const driver = await getRemoteDriverForInstance({
      id: instance.id,
      slug: instance.slug,
      isRemote: instance.isRemote,
      agentUrl: instance.agentUrl,
    });

    // 1. Trigger the backup on the agent (this blocks until backup.sh completes)
    logger.info(`[backup] ${instance.slug}: triggering remote backup via agent`);
    const result = await driver.createBackup();
    agentBackupId = result.backupId;

    logger.info(
      `[backup] ${instance.slug}: agent backup complete — ${result.filename} ` +
      `(${(result.sizeBytes / 1024 / 1024).toFixed(1)} MB, sha256=${result.sha256.substring(0, 16)}...)`
    );

    // 2. Resolve the destination archive path on CCP storage
    const archiveName = `backup-${instance.slug}-${result.backupId}.tar.gz`;
    archivePath = path.join(env.BACKUP_STORAGE_PATH, instance.slug, archiveName);
    // Path traversal guard (slug should be safe but better to assert)
    assertPathWithinBoundary(archivePath, env.BACKUP_STORAGE_PATH, 'Backup archive');
    await fs.mkdir(path.dirname(archivePath), { recursive: true });

    // 3. Stream the archive from the agent to CCP storage
    logger.info(`[backup] ${instance.slug}: streaming archive to ${archivePath}`);
    const { bytesWritten } = await driver.downloadBackup(result.backupId, archivePath);
    if (bytesWritten !== result.sizeBytes) {
      throw new Error(
        `Downloaded size ${bytesWritten} does not match agent-reported size ${result.sizeBytes}`
      );
    }

    // 4. Re-hash the downloaded file and compare to the agent-reported hash.
    //
    // SECURITY NOTE: this check authenticates *transmission integrity* only,
    // not content integrity against a malicious agent. Both the file bytes
    // and the expected hash are supplied by the (semi-trusted) agent, so a
    // compromised agent can trivially make this check pass while delivering
    // arbitrary content. The check still catches accidental corruption (bit
    // flips, truncation) and is essentially free.
    //
    // The mTLS channel guarantees that the bytes weren't modified in transit
    // by an outside attacker. The remaining trust gap — "what if the agent
    // itself is compromised?" — must be addressed before Phase B (restore)
    // ships, since restore feeds the archive into pg_restore. Either:
    //   (a) HMAC-sign the hash on the agent with its mTLS private key and
    //       verify on the CCP using the agent cert public key, or
    //   (b) limit restore operations to require an additional out-of-band
    //       admin confirmation step.
    const localSha256 = await fileHash(archivePath);
    if (localSha256 !== result.sha256) {
      throw new Error(
        `SHA256 mismatch: agent reported ${result.sha256}, local file hashed ${localSha256}`
      );
    }

    // 5. Reclaim disk on the remote agent
    try {
      await driver.deleteBackup(result.backupId);
    } catch (err) {
      logger.warn(
        `[backup] ${instance.slug}: failed to delete remote backup ${result.backupId}: ${(err as Error).message}`
      );
      // Non-fatal — CCP has the archive, remote copy will age out next retention sweep
    }

    // 6. Persist the result. Store sha256 and agentBackupId inside the manifest
    // since we don't have dedicated columns.
    const mergedManifest = {
      ...(result.manifest as Record<string, unknown> | null ?? {}),
      source: 'remote',
      agentBackupId: result.backupId,
      sha256: result.sha256,
      createdAt: result.createdAt,
    };

    await prisma.backup.update({
      where: { id: backupId },
      data: {
        status: BackupStatus.COMPLETED,
        archivePath,
        sizeBytes: BigInt(bytesWritten),
        manifest: mergedManifest as unknown as Prisma.InputJsonValue,
        completedAt: new Date(),
      },
    });

    if (userId) {
      await prisma.auditLog.create({
        data: {
          userId,
          instanceId: instance.id,
          action: AuditAction.BACKUP_CREATE,
          details: {
            backupId,
            archiveName,
            sizeBytes: bytesWritten,
            source: 'remote',
            agentBackupId: result.backupId,
          },
          ipAddress,
        },
      });
    }

    logger.info(
      `[backup] ${instance.slug}: remote backup stored at ${archivePath} ` +
      `(${(bytesWritten / 1024 / 1024).toFixed(1)} MB)`
    );
  } catch (err) {
    await prisma.backup.update({
      where: { id: backupId },
      data: {
        status: BackupStatus.FAILED,
        errorMessage: (err as Error).message,
        completedAt: new Date(),
      },
    });

    // Clean up any partial local file; leave the remote copy so retry is possible
    if (archivePath) {
      try { await fs.unlink(archivePath); } catch { /* ignore */ }
    }
    if (agentBackupId) {
      logger.warn(
        `[backup] ${instance.slug}: leaving agent-side backup ${agentBackupId} in place for retry`
      );
    }

    throw err;
  }
}

/**
 * Delete a backup (file + DB record).
 */
export async function deleteBackup(backupId: string, userId?: string, ipAddress?: string) {
  const backup = await prisma.backup.findUnique({
    where: { id: backupId },
    include: { instance: { select: { id: true, slug: true } } },
  });
  if (!backup) {
    throw new AppError(404, 'Backup not found', 'NOT_FOUND');
  }

  // Delete archive file (validate path boundary to prevent arbitrary file deletion)
  if (backup.archivePath) {
    assertPathWithinBoundary(backup.archivePath, env.BACKUP_STORAGE_PATH, 'Backup archive');
    try {
      await fs.unlink(backup.archivePath);
    } catch {
      logger.warn(`[backup] Could not delete file: ${backup.archivePath}`);
    }
  }

  await prisma.backup.delete({ where: { id: backupId } });

  if (userId) {
    await prisma.auditLog.create({
      data: {
        userId,
        instanceId: backup.instanceId,
        action: AuditAction.BACKUP_DELETE,
        details: { backupId, instanceSlug: backup.instance?.slug },
        ipAddress,
      },
    });
  }
}

/**
 * List backups with optional instance filter and pagination.
 */
export async function listBackups(instanceId?: string, page = 1, limit = 50) {
  const where = instanceId ? { instanceId } : {};

  const [data, total] = await Promise.all([
    prisma.backup.findMany({
      where,
      orderBy: { startedAt: 'desc' },
      skip: (page - 1) * limit,
      take: limit,
      include: {
        instance: { select: { id: true, name: true, slug: true } },
      },
    }),
    prisma.backup.count({ where }),
  ]);

  return { data, total, page, limit };
}

/**
 * Get a single backup by ID.
 */
export async function getBackup(backupId: string) {
  const backup = await prisma.backup.findUnique({ where: { id: backupId } });
  if (!backup) {
    throw new AppError(404, 'Backup not found', 'NOT_FOUND');
  }
  return backup;
}

/**
 * Cleanup backups older than retention period.
 */
export async function cleanupOldBackups(retentionDays: number): Promise<number> {
  const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);

  const oldBackups = await prisma.backup.findMany({
    where: {
      startedAt: { lt: cutoff },
      status: { in: [BackupStatus.COMPLETED, BackupStatus.FAILED] },
    },
  });

  let deleted = 0;
  for (const backup of oldBackups) {
    try {
      if (backup.archivePath) {
        assertPathWithinBoundary(backup.archivePath, env.BACKUP_STORAGE_PATH, 'Backup archive');
        await fs.unlink(backup.archivePath);
      }
      await prisma.backup.delete({ where: { id: backup.id } });
      deleted++;
    } catch (err) {
      logger.warn(`[backup] Failed to cleanup backup ${backup.id}: ${(err as Error).message}`);
    }
  }

  if (deleted > 0) {
    logger.info(`[backup] Cleaned up ${deleted} old backups (>${retentionDays} days)`);
  }

  return deleted;
}
