import { Router, Request, Response } from 'express';
import { param } from '../utils/params';
import fs from 'fs/promises';
import { createReadStream, createWriteStream } from 'fs';
import { pipeline as pipelineCb, Transform } from 'stream';
import { promisify } from 'util';
import path from 'path';
import crypto from 'crypto';
import { spawn } from 'child_process';
import { getSlugEntry } from '../services/registry.service';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { withSlugLock, SlugBusyError, isSlugLocked } from '../services/slug-mutex';
import { AgentError } from '../middleware/error-handler';

const pipeline = promisify(pipelineCb);

const router = Router();

// ─── Helpers ──────────────────────────────────────────────────────────

const ID_REGEX = /^[a-zA-Z0-9_-]+$/;
const ARCHIVE_PREFIX = 'changemaker-v2-backup-';
const ARCHIVE_SUFFIX = '.tar.gz';

function backupsDirFor(slug: string): string {
  return path.join(env.AGENT_DATA_DIR, 'backups', slug);
}

function archivePathFor(slug: string, id: string): string {
  return path.join(backupsDirFor(slug), `${ARCHIVE_PREFIX}${id}${ARCHIVE_SUFFIX}`);
}

async function sha256File(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);
  });
}

/**
 * Read the manifest.json out of a backup archive without extracting it.
 * backup.sh stores it at <archive>/changemaker-v2-backup-<ts>/manifest.json
 */
async function readManifestFromArchive(archivePath: string): Promise<unknown | null> {
  return new Promise((resolve) => {
    const proc = spawn('tar', ['-xzOf', archivePath, '--wildcards', '*/manifest.json'], {
      stdio: ['ignore', 'pipe', 'ignore'],
    });
    let buf = '';
    proc.stdout.on('data', (chunk) => (buf += chunk.toString('utf-8')));
    proc.on('error', () => resolve(null));
    proc.on('close', (code) => {
      if (code !== 0 || !buf.trim()) return resolve(null);
      try {
        resolve(JSON.parse(buf));
      } catch {
        resolve(null);
      }
    });
  });
}

/**
 * Extract the timestamp ID from a filename like "changemaker-v2-backup-20260409_143000.tar.gz".
 */
function idFromFilename(filename: string): string | null {
  if (!filename.startsWith(ARCHIVE_PREFIX) || !filename.endsWith(ARCHIVE_SUFFIX)) return null;
  return filename.slice(ARCHIVE_PREFIX.length, filename.length - ARCHIVE_SUFFIX.length);
}

// ─── Routes ───────────────────────────────────────────────────────────

/**
 * POST /instance/:slug/backup
 * Shells out to the remote CML's scripts/backup.sh. Returns archive metadata
 * so the CCP can immediately stream it down via the /download endpoint.
 */
router.post('/instance/:slug/backup', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  const entry = await getSlugEntry(slug);

  try {
    const result = await withSlugLock(slug, 'backup', async () => {
      const backupsDir = backupsDirFor(slug);
      await fs.mkdir(backupsDir, { recursive: true });

      // Verify scripts/backup.sh exists
      const scriptPath = path.join(entry.basePath, 'scripts', 'backup.sh');
      try {
        await fs.access(scriptPath);
      } catch {
        throw new AgentError(500, `scripts/backup.sh not found at ${scriptPath}`, 'BACKUP_SCRIPT_MISSING');
      }

      // Snapshot existing archive filenames so we can identify the new one
      const existingFiles = new Set(
        (await fs.readdir(backupsDir)).filter((f) => f.startsWith(ARCHIVE_PREFIX) && f.endsWith(ARCHIVE_SUFFIX))
      );

      const logPath = path.join(backupsDir, `backup-${Date.now()}.log`);
      const logFd = await fs.open(logPath, 'w');

      // Spawn backup.sh with cwd=basePath so its .env detection works.
      // Retention is effectively disabled here — CCP manages retention of
      // the streamed-down archives, not the agent's transient copies.
      //
      // Container names: backup.sh defaults to `changemaker-v2-postgres` and
      // `listmonk-db`, which match the main CML's `container_name:` overrides.
      // If a deployment has custom naming, the operator can set PG_CONTAINER /
      // LISTMONK_PG_CONTAINER in the instance's own .env (backup.sh loads it).
      const spawnEnv: NodeJS.ProcessEnv = {
        ...process.env,
        BACKUP_DIR: backupsDir,
        RETENTION_DAYS: '36500', // ~100 years; CCP controls retention
      };

      logger.info(`[backup] Running scripts/backup.sh for ${slug} (basePath=${entry.basePath})`);

      const exitCode: number = await new Promise((resolve, reject) => {
        const proc = spawn('bash', ['scripts/backup.sh'], {
          cwd: entry.basePath,
          env: spawnEnv,
          stdio: ['ignore', 'pipe', 'pipe'],
        });
        proc.stdout.on('data', (chunk) => logFd.write(chunk).catch(() => {}));
        proc.stderr.on('data', (chunk) => logFd.write(chunk).catch(() => {}));
        proc.on('error', reject);
        proc.on('close', (code) => resolve(code ?? 1));
      });

      await logFd.close();

      if (exitCode !== 0) {
        // Return the tail of the log so the CCP can display it
        let logTail = '';
        try {
          const fullLog = await fs.readFile(logPath, 'utf-8');
          logTail = fullLog.split('\n').slice(-40).join('\n');
        } catch { /* ignore */ }
        throw new AgentError(500, `backup.sh exited with code ${exitCode}\n${logTail}`, 'BACKUP_FAILED');
      }

      // Find the new archive
      const afterFiles = (await fs.readdir(backupsDir)).filter(
        (f) => f.startsWith(ARCHIVE_PREFIX) && f.endsWith(ARCHIVE_SUFFIX)
      );
      const newFiles = afterFiles.filter((f) => !existingFiles.has(f));
      if (newFiles.length === 0) {
        throw new AgentError(500, 'backup.sh succeeded but no new archive was created', 'BACKUP_NO_OUTPUT');
      }
      // Pick the most recently modified (in case of oddities)
      newFiles.sort();
      const newest = newFiles[newFiles.length - 1] as string;
      const archivePath = path.join(backupsDir, newest);
      const backupId = idFromFilename(newest);
      if (!backupId || !ID_REGEX.test(backupId)) {
        throw new AgentError(500, `Unexpected archive filename: ${newest}`, 'BACKUP_NAME_INVALID');
      }

      const stats = await fs.stat(archivePath);
      const sha256 = await sha256File(archivePath);
      const manifest = await readManifestFromArchive(archivePath);

      // Delete the log file once we know the backup succeeded
      try { await fs.unlink(logPath); } catch { /* ignore */ }

      logger.info(`[backup] ${slug}: created ${newest} (${stats.size} bytes, sha256=${sha256.substring(0, 16)}...)`);

      return {
        backupId,
        filename: newest,
        sizeBytes: stats.size,
        sha256,
        manifest,
        createdAt: stats.mtime.toISOString(),
      };
    });

    res.json(result);
  } catch (err) {
    if (err instanceof SlugBusyError) {
      res.status(409).json({ error: 'SLUG_BUSY', message: err.message });
      return;
    }
    throw err;
  }
});

/**
 * GET /instance/:slug/backups
 * Lists backup archives currently held on the agent for this slug.
 */
router.get('/instance/:slug/backups', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  await getSlugEntry(slug); // validate slug is registered

  const backupsDir = backupsDirFor(slug);
  let entries: string[] = [];
  try {
    entries = await fs.readdir(backupsDir);
  } catch {
    res.json({ data: [] });
    return;
  }

  const results = [];
  for (const filename of entries) {
    const id = idFromFilename(filename);
    if (!id) continue;
    try {
      const stats = await fs.stat(path.join(backupsDir, filename));
      results.push({
        backupId: id,
        filename,
        sizeBytes: stats.size,
        createdAt: stats.mtime.toISOString(),
      });
    } catch { /* skip */ }
  }
  results.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
  res.json({ data: results });
});

/**
 * GET /instance/:slug/backup/:id/download
 * Streams the backup archive (supports Content-Length so the CCP can verify size).
 */
router.get('/instance/:slug/backup/:id/download', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  const id = param(req, 'id');
  if (!ID_REGEX.test(id)) {
    res.status(400).json({ error: 'INVALID_ID', message: 'Invalid backup id' });
    return;
  }
  await getSlugEntry(slug);

  const archivePath = archivePathFor(slug, id);
  try {
    const stats = await fs.stat(archivePath);
    res.setHeader('Content-Type', 'application/gzip');
    res.setHeader('Content-Length', String(stats.size));
    res.setHeader('Content-Disposition', `attachment; filename="${path.basename(archivePath)}"`);
    const stream = createReadStream(archivePath);
    stream.on('error', (err) => {
      logger.error(`[backup] stream error for ${archivePath}: ${err.message}`);
      if (!res.headersSent) res.status(500).end();
      else res.destroy(err);
    });
    stream.pipe(res);
  } catch {
    res.status(404).json({ error: 'NOT_FOUND', message: 'Backup archive not found' });
    return;
  }
});

/**
 * DELETE /instance/:slug/backup/:id
 * Deletes the archive from the agent's disk. The CCP calls this after it has
 * successfully streamed the archive to its own storage.
 */
router.delete('/instance/:slug/backup/:id', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  const id = param(req, 'id');
  if (!ID_REGEX.test(id)) {
    res.status(400).json({ error: 'INVALID_ID', message: 'Invalid backup id' });
    return;
  }
  await getSlugEntry(slug);

  const archivePath = archivePathFor(slug, id);
  // Path traversal defense: ensure the resolved path is still inside the slug's backups dir
  const resolved = path.resolve(archivePath);
  const boundary = path.resolve(backupsDirFor(slug));
  if (!resolved.startsWith(boundary + path.sep)) {
    res.status(400).json({ error: 'INVALID_ID', message: 'Invalid backup id' });
    return;
  }

  try {
    await fs.unlink(archivePath);
    logger.info(`[backup] ${slug}: deleted ${path.basename(archivePath)}`);
    res.json({ deleted: true });
  } catch (err) {
    const code = (err as NodeJS.ErrnoException).code;
    if (code === 'ENOENT') {
      res.status(404).json({ error: 'NOT_FOUND', message: 'Backup archive not found' });
      return;
    }
    throw err;
  }
});

// ─── Restore ──────────────────────────────────────────────────────────

// Hard cap on a single restore upload. The CCP is trusted, but a buggy or
// compromised CCP shouldn't be able to fill the agent's disk in one request.
// 20 GB is well above any realistic Changemaker Lite backup size.
const MAX_RESTORE_UPLOAD_BYTES = 20 * 1024 * 1024 * 1024;

function restoresDirFor(slug: string): string {
  return path.join(env.AGENT_DATA_DIR, 'restores', slug);
}

function restoreUploadDir(slug: string, uploadId: string): string {
  return path.join(restoresDirFor(slug), uploadId);
}

interface RestoreState {
  status: 'UPLOADED' | 'RUNNING' | 'COMPLETED' | 'FAILED';
  uploadId: string;
  startedAt: string;
  completedAt?: string;
  exitCode?: number;
  logTail?: string;
  errorMessage?: string;
  options?: Record<string, unknown>;
}

async function readRestoreState(slug: string, uploadId: string): Promise<RestoreState | null> {
  const statePath = path.join(restoreUploadDir(slug, uploadId), 'restore-state.json');
  try {
    const content = await fs.readFile(statePath, 'utf-8');
    return JSON.parse(content) as RestoreState;
  } catch {
    return null;
  }
}

async function writeRestoreState(slug: string, uploadId: string, state: RestoreState): Promise<void> {
  const statePath = path.join(restoreUploadDir(slug, uploadId), 'restore-state.json');
  await fs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');
}

/**
 * POST /instance/:slug/restore/upload?sha256=<hex>
 * Accepts an application/octet-stream upload of a backup archive and writes
 * it to the agent's restores directory. Verifies SHA256 as it streams — if
 * the hash doesn't match, the partial file is deleted and we return 400.
 *
 * Returns `{ uploadId, sizeBytes, sha256 }`.
 */
router.post('/instance/:slug/restore/upload', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  await getSlugEntry(slug);

  if (isSlugLocked(slug, 'restore')) {
    res.status(409).json({ error: 'SLUG_BUSY', message: 'A restore is already in progress for this slug' });
    return;
  }
  if (isSlugLocked(slug, 'backup')) {
    res.status(409).json({ error: 'SLUG_BUSY', message: 'A backup is in progress for this slug' });
    return;
  }

  const expectedSha256 = typeof req.query.sha256 === 'string' ? req.query.sha256.toLowerCase() : undefined;
  if (!expectedSha256 || !/^[a-f0-9]{64}$/.test(expectedSha256)) {
    res.status(400).json({ error: 'VALIDATION', message: 'sha256 query parameter required (64 hex chars)' });
    return;
  }

  const uploadId = crypto.randomBytes(16).toString('hex');
  const uploadDir = restoreUploadDir(slug, uploadId);
  await fs.mkdir(uploadDir, { recursive: true });
  const archivePath = path.join(uploadDir, 'archive.tar.gz');

  const hash = crypto.createHash('sha256');
  let bytesWritten = 0;
  const hashTransform = new Transform({
    transform(chunk: Buffer, _enc, cb) {
      bytesWritten += chunk.length;
      if (bytesWritten > MAX_RESTORE_UPLOAD_BYTES) {
        // Abort the stream — pipeline() will reject and the catch block below
        // will remove the partial upload directory.
        cb(new AgentError(
          413,
          `Upload exceeds maximum allowed size of ${MAX_RESTORE_UPLOAD_BYTES} bytes`,
          'UPLOAD_TOO_LARGE'
        ));
        return;
      }
      hash.update(chunk);
      cb(null, chunk);
    },
  });

  try {
    const writeStream = createWriteStream(archivePath);
    await pipeline(req, hashTransform, writeStream);
    const sha256 = hash.digest('hex');

    if (sha256 !== expectedSha256) {
      // Integrity failure — nuke the upload
      await fs.rm(uploadDir, { recursive: true, force: true });
      res.status(400).json({
        error: 'SHA256_MISMATCH',
        message: `Expected sha256 ${expectedSha256}, got ${sha256}`,
      });
      return;
    }

    const stats = await fs.stat(archivePath);

    // Persist initial state so the progress endpoint works even before apply
    await writeRestoreState(slug, uploadId, {
      status: 'UPLOADED',
      uploadId,
      startedAt: new Date().toISOString(),
    });

    logger.info(`[restore] ${slug}: uploaded ${bytesWritten} bytes (sha256=${sha256.substring(0, 16)}...) upload_id=${uploadId}`);

    res.json({
      uploadId,
      sizeBytes: stats.size,
      sha256,
    });
  } catch (err) {
    // Stream error or write error — clean up
    try { await fs.rm(uploadDir, { recursive: true, force: true }); } catch { /* ignore */ }
    throw err;
  }
});

/**
 * POST /instance/:slug/restore/:uploadId/apply
 * Body: { confirm: true, skipDb?, skipUploads?, skipListmonk?, dryRun? }
 *
 * Fires off `scripts/restore.sh --archive <path> --force` in the background
 * and writes progress to restore-state.json. The CCP polls the progress
 * endpoint for updates. Mutex prevents concurrent restores/backups.
 */
router.post('/instance/:slug/restore/:uploadId/apply', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  const uploadId = param(req, 'uploadId');
  if (!ID_REGEX.test(uploadId)) {
    res.status(400).json({ error: 'INVALID_ID', message: 'Invalid upload id' });
    return;
  }
  const entry = await getSlugEntry(slug);

  const { confirm, skipDb, skipUploads, skipListmonk, dryRun } = req.body ?? {};
  if (confirm !== true) {
    res.status(400).json({ error: 'CONFIRMATION_REQUIRED', message: 'Body must include { confirm: true }' });
    return;
  }

  const uploadDir = restoreUploadDir(slug, uploadId);
  // Path traversal defense
  const resolvedDir = path.resolve(uploadDir);
  const boundary = path.resolve(restoresDirFor(slug));
  if (!resolvedDir.startsWith(boundary + path.sep)) {
    res.status(400).json({ error: 'INVALID_ID', message: 'Invalid upload id' });
    return;
  }

  const archivePath = path.join(uploadDir, 'archive.tar.gz');
  try {
    await fs.access(archivePath);
  } catch {
    res.status(404).json({ error: 'NOT_FOUND', message: 'Upload not found or already applied' });
    return;
  }

  // Verify scripts/restore.sh exists
  const scriptPath = path.join(entry.basePath, 'scripts', 'restore.sh');
  try {
    await fs.access(scriptPath);
  } catch {
    res.status(500).json({ error: 'RESTORE_SCRIPT_MISSING', message: `scripts/restore.sh not found at ${scriptPath}` });
    return;
  }

  // Check mutex state (don't block — tell caller it's busy)
  if (isSlugLocked(slug, 'restore') || isSlugLocked(slug, 'backup')) {
    res.status(409).json({ error: 'SLUG_BUSY', message: 'Slug is busy with backup or restore' });
    return;
  }

  // Fire-and-forget: acquire lock and run in background. Return immediately
  // so CCP can start polling /progress.
  const options = {
    skipDb: !!skipDb,
    skipUploads: !!skipUploads,
    skipListmonk: !!skipListmonk,
    dryRun: !!dryRun,
  };

  await writeRestoreState(slug, uploadId, {
    status: 'RUNNING',
    uploadId,
    startedAt: new Date().toISOString(),
    options,
  });

  // Build restore.sh args (all flags, no user input interpolated into a shell string)
  const args = ['scripts/restore.sh', '--archive', archivePath, '--force'];
  if (options.skipDb) args.push('--skip-db');
  if (options.skipUploads) args.push('--skip-uploads');
  if (options.skipListmonk) args.push('--skip-listmonk');
  if (options.dryRun) args.push('--dry-run');

  const logPath = path.join(uploadDir, 'restore.log');

  // Schedule the background task — don't await inside the handler
  void withSlugLock(slug, 'restore', async () => {
    const logFd = await fs.open(logPath, 'w');
    logger.info(`[restore] ${slug}: running ${args.join(' ')} (cwd=${entry.basePath})`);

    const exitCode: number = await new Promise((resolve, reject) => {
      const proc = spawn('bash', args, {
        cwd: entry.basePath,
        env: { ...process.env },
        stdio: ['ignore', 'pipe', 'pipe'],
      });
      proc.stdout.on('data', (chunk) => logFd.write(chunk).catch(() => {}));
      proc.stderr.on('data', (chunk) => logFd.write(chunk).catch(() => {}));
      proc.on('error', reject);
      proc.on('close', (code) => resolve(code ?? 1));
    });

    await logFd.close();

    // Read the tail of the log for the state file
    let logTail = '';
    try {
      const fullLog = await fs.readFile(logPath, 'utf-8');
      logTail = fullLog.split('\n').slice(-80).join('\n');
    } catch { /* ignore */ }

    const state: RestoreState = {
      status: exitCode === 0 ? 'COMPLETED' : 'FAILED',
      uploadId,
      startedAt: (await readRestoreState(slug, uploadId))?.startedAt || new Date().toISOString(),
      completedAt: new Date().toISOString(),
      exitCode,
      logTail,
      options,
      ...(exitCode !== 0 ? { errorMessage: `restore.sh exited with code ${exitCode}` } : {}),
    };
    await writeRestoreState(slug, uploadId, state);

    logger.info(`[restore] ${slug}: restore.sh finished with exit ${exitCode}`);
  }).catch(async (err) => {
    logger.error(`[restore] ${slug}: background restore failed: ${(err as Error).message}`);
    // If the mutex was the issue, state is already written. Otherwise, mark failed.
    if (!(err instanceof SlugBusyError)) {
      try {
        await writeRestoreState(slug, uploadId, {
          status: 'FAILED',
          uploadId,
          startedAt: new Date().toISOString(),
          completedAt: new Date().toISOString(),
          errorMessage: (err as Error).message,
          options,
        });
      } catch { /* ignore */ }
    }
  });

  res.status(202).json({ applied: true, uploadId, options });
});

/**
 * GET /instance/:slug/restore/:uploadId/progress
 * Returns the current state of a running or completed restore.
 */
router.get('/instance/:slug/restore/:uploadId/progress', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  const uploadId = param(req, 'uploadId');
  if (!ID_REGEX.test(uploadId)) {
    res.status(400).json({ error: 'INVALID_ID', message: 'Invalid upload id' });
    return;
  }
  await getSlugEntry(slug);

  const state = await readRestoreState(slug, uploadId);
  if (!state) {
    res.status(404).json({ error: 'NOT_FOUND', message: 'Restore not found' });
    return;
  }
  res.json(state);
});

/**
 * DELETE /instance/:slug/restore/:uploadId
 * Removes a restore upload directory. Refuses if a restore is currently running.
 */
router.delete('/instance/:slug/restore/:uploadId', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  const uploadId = param(req, 'uploadId');
  if (!ID_REGEX.test(uploadId)) {
    res.status(400).json({ error: 'INVALID_ID', message: 'Invalid upload id' });
    return;
  }
  await getSlugEntry(slug);

  const uploadDir = restoreUploadDir(slug, uploadId);
  const resolvedDir = path.resolve(uploadDir);
  const boundary = path.resolve(restoresDirFor(slug));
  if (!resolvedDir.startsWith(boundary + path.sep)) {
    res.status(400).json({ error: 'INVALID_ID', message: 'Invalid upload id' });
    return;
  }

  const state = await readRestoreState(slug, uploadId);
  if (state?.status === 'RUNNING') {
    res.status(409).json({ error: 'RESTORE_RUNNING', message: 'Cannot delete a running restore' });
    return;
  }

  try {
    await fs.rm(uploadDir, { recursive: true, force: true });
    res.json({ deleted: true });
  } catch (err) {
    throw err;
  }
});

export default router;
