import { Router, Request, Response } from 'express';
import { param } from '../utils/params';
import { execFile, spawn } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import { getSlugEntry } from '../services/registry.service';
import { logger } from '../utils/logger';
import { withSlugLock, SlugBusyError, isSlugLocked } from '../services/slug-mutex';

const execFileAsync = promisify(execFile);
const router = Router();

/** Validate a git branch name — prevent shell injection. */
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;

/**
 * Max age of an in-progress upgrade (by progress.json mtime) before we
 * consider a previous attempt dead and allow a new one through.
 *
 * SECURITY NOTE: this must be LONGER than the CCP's REMOTE_UPGRADE_TIMEOUT
 * AND longer than any realistic legitimate upgrade duration. The concern is
 * a concurrent-upgrade scenario:
 *   - upgrade.sh is running and legitimately slow (large image pull + DB
 *     migration)
 *   - at 15 min the CCP side times out and marks the row FAILED
 *   - admin clicks "Upgrade" again → CCP's DB check sees no active row
 *   - if this staleness window is <= realistic upgrade time, the second
 *     /upgrade/start call would ALSO pass this check, spawning a second
 *     upgrade.sh process racing against the still-running first one
 *
 * 45 min gives headroom over the 15-min CCP timeout and covers realistic
 * upgrade durations. For a truly bulletproof guard, switch to a PID lock
 * file that verifies the process is still alive.
 */
const STALE_UPGRADE_MTIME_MS = 45 * 60 * 1000;

/**
 * Returns true if there's an in-progress upgrade visible on disk.
 *
 * Used as a second-line guard in case the in-memory mutex was lost to an
 * agent restart mid-upgrade. The check looks at progress.json mtime and
 * the absence of a result.json — together they indicate "started but not
 * finished within the staleness window".
 */
async function isUpgradeRunningOnDisk(basePath: string): Promise<boolean> {
  const progressPath = path.join(basePath, 'data', 'upgrade', 'progress.json');
  const resultPath = path.join(basePath, 'data', 'upgrade', 'result.json');

  let progressStat: import('fs').Stats;
  try {
    progressStat = await fs.stat(progressPath);
  } catch {
    return false; // no progress file → no in-progress upgrade
  }

  // If a result file exists with mtime >= progress mtime, the run is finished
  try {
    const resultStat = await fs.stat(resultPath);
    if (resultStat.mtimeMs >= progressStat.mtimeMs) return false;
  } catch { /* no result file yet */ }

  // Stale: progress file is old and no result was written → assume the
  // previous attempt died and let a new one through
  if (Date.now() - progressStat.mtimeMs > STALE_UPGRADE_MTIME_MS) return false;

  return true;
}

// POST /instance/:slug/upgrade/check — Run upgrade-check.sh and return status.json
router.post('/instance/:slug/upgrade/check', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  const entry = await getSlugEntry(slug);

  // Refuse during a running upgrade — check writes status.json which could
  // race with upgrade.sh writing other files in data/upgrade/
  if (isSlugLocked(slug, 'upgrade') || await isUpgradeRunningOnDisk(entry.basePath)) {
    res.status(409).json({ error: 'SLUG_BUSY', message: 'An upgrade is currently running' });
    return;
  }

  const scriptPath = path.join(entry.basePath, 'scripts', 'upgrade-check.sh');
  try {
    await fs.access(scriptPath);
  } catch {
    res.status(404).json({ error: 'SCRIPT_NOT_FOUND', message: `upgrade-check.sh not found at ${scriptPath}` });
    return;
  }

  // Run upgrade-check.sh — it writes data/upgrade/status.json. Use execFile
  // (no shell) and a 60s timeout. Failures are non-fatal: the script may
  // still have written status.json before erroring out, so we always try
  // to read it afterwards.
  try {
    await execFileAsync('bash', [scriptPath], {
      cwd: entry.basePath,
      timeout: 60_000,
      maxBuffer: 4 * 1024 * 1024,
      env: { ...process.env, COMPOSE_ANSI: 'never' },
    });
  } catch (err) {
    logger.warn(`[upgrade] ${slug}: upgrade-check.sh failed: ${(err as Error).message}`);
    // continue — try to read status.json anyway
  }

  const statusPath = path.join(entry.basePath, 'data', 'upgrade', 'status.json');
  try {
    const content = await fs.readFile(statusPath, 'utf-8');
    res.json(JSON.parse(content));
  } catch {
    res.status(500).json({ error: 'STATUS_NOT_AVAILABLE', message: 'upgrade-check.sh did not produce status.json' });
  }
});

// POST /instance/:slug/upgrade/start — Run upgrade.sh in the background
router.post('/instance/:slug/upgrade/start', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  const entry = await getSlugEntry(slug);
  const { skipBackup, useRegistry, branch } = req.body || {};

  // SECURITY: Validate branch name to prevent injection
  if (branch && !SAFE_BRANCH.test(branch)) {
    res.status(400).json({ error: 'VALIDATION', message: 'Invalid branch name' });
    return;
  }

  const scriptPath = path.join(entry.basePath, 'scripts', 'upgrade.sh');
  try {
    await fs.access(scriptPath);
  } catch {
    res.status(404).json({ error: 'NOT_FOUND', message: 'upgrade.sh not found' });
    return;
  }

  // Refuse if an upgrade is already running (in-memory or on-disk indicators)
  if (isSlugLocked(slug, 'upgrade') || await isUpgradeRunningOnDisk(entry.basePath)) {
    res.status(409).json({ error: 'SLUG_BUSY', message: 'An upgrade is already in progress' });
    return;
  }
  // Backup or restore concurrency: refuse to start an upgrade while either is running
  if (isSlugLocked(slug, 'backup') || isSlugLocked(slug, 'restore')) {
    res.status(409).json({ error: 'SLUG_BUSY', message: 'A backup or restore is currently running' });
    return;
  }

  // Clear stale progress/result files before starting so the on-disk staleness
  // check doesn't think a brand-new upgrade is still finishing.
  const progressPath = path.join(entry.basePath, 'data', 'upgrade', 'progress.json');
  const resultPath = path.join(entry.basePath, 'data', 'upgrade', 'result.json');
  await fs.mkdir(path.dirname(progressPath), { recursive: true });
  await fs.rm(progressPath, { force: true });
  await fs.rm(resultPath, { force: true });

  // SECURITY: Use spawn with args array — no shell interpolation
  const args: string[] = [scriptPath, '--api-mode', '--force'];
  if (skipBackup) args.push('--skip-backup');
  if (useRegistry) args.push('--use-registry');
  if (branch) args.push('--branch', branch);

  // Schedule the background task under the slug lock. Use void so the
  // promise doesn't block the response. Errors are caught and logged; the
  // CCP detects them via the absence of a result file or via the timeout.
  void withSlugLock(slug, 'upgrade', async () => {
    logger.info(`[upgrade] ${slug}: spawning ${args.join(' ')} (cwd=${entry.basePath})`);
    try {
      await new Promise<void>((resolve, reject) => {
        const proc = spawn('bash', args, {
          cwd: entry.basePath,
          env: { ...process.env, COMPOSE_ANSI: 'never' },
          stdio: ['ignore', 'ignore', 'ignore'], // upgrade.sh writes its own logs
        });
        proc.on('error', reject);
        proc.on('close', (code) => {
          if (code === 0) resolve();
          else reject(new Error(`upgrade.sh exited with code ${code}`));
        });
      });
      logger.info(`[upgrade] ${slug}: upgrade.sh completed`);
    } catch (err) {
      logger.error(`[upgrade] ${slug}: ${(err as Error).message}`);
    }
  }).catch((err) => {
    if (!(err instanceof SlugBusyError)) {
      logger.error(`[upgrade] ${slug}: lock or background error: ${(err as Error).message}`);
    }
  });

  res.status(202).json({ started: true });
});

// POST /instance/:slug/upgrade/start-image-only — Run image-upgrade.sh in background
//
// Image-only upgrade: pulls latest images + recreates services without touching
// tracked files (no git pull, no tarball extract, no VERSION mutation). Tenant
// content is implicitly safe because the script never writes outside data/upgrade.
// See scripts/image-upgrade.sh for full rationale.
//
// Schema-compatible with /upgrade/start: writes the same progress.json + result.json
// so the CCP poll loop in runRemoteUpgrade() works unchanged.
router.post('/instance/:slug/upgrade/start-image-only', async (req: Request, res: Response) => {
  const slug = param(req, 'slug');
  const entry = await getSlugEntry(slug);
  const { imageTag } = req.body || {};

  // SECURITY: imageTag flows into bash via --image-tag. Constrain to a safe
  // subset of docker tag chars (semver, SHA, named tags). Reject anything
  // that could shell-escape.
  if (imageTag && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(String(imageTag))) {
    res.status(400).json({ error: 'VALIDATION', message: 'Invalid imageTag' });
    return;
  }

  const scriptPath = path.join(entry.basePath, 'scripts', 'image-upgrade.sh');
  try {
    await fs.access(scriptPath);
  } catch {
    res.status(404).json({ error: 'NOT_FOUND', message: 'image-upgrade.sh not found' });
    return;
  }

  // Same concurrency guards as the full /upgrade/start endpoint — uses the
  // same lock + on-disk staleness check + backup/restore mutex.
  if (isSlugLocked(slug, 'upgrade') || await isUpgradeRunningOnDisk(entry.basePath)) {
    res.status(409).json({ error: 'SLUG_BUSY', message: 'An upgrade is already in progress' });
    return;
  }
  if (isSlugLocked(slug, 'backup') || isSlugLocked(slug, 'restore')) {
    res.status(409).json({ error: 'SLUG_BUSY', message: 'A backup or restore is currently running' });
    return;
  }

  // Clear stale progress/result files (same convention as /upgrade/start)
  const progressPath = path.join(entry.basePath, 'data', 'upgrade', 'progress.json');
  const resultPath = path.join(entry.basePath, 'data', 'upgrade', 'result.json');
  await fs.mkdir(path.dirname(progressPath), { recursive: true });
  await fs.rm(progressPath, { force: true });
  await fs.rm(resultPath, { force: true });

  const args: string[] = [scriptPath, '--api-mode'];
  if (imageTag) args.push('--image-tag', String(imageTag));

  void withSlugLock(slug, 'upgrade', async () => {
    logger.info(`[image-upgrade] ${slug}: spawning ${args.join(' ')} (cwd=${entry.basePath})`);
    try {
      await new Promise<void>((resolve, reject) => {
        const proc = spawn('bash', args, {
          cwd: entry.basePath,
          env: { ...process.env, COMPOSE_ANSI: 'never' },
          stdio: ['ignore', 'ignore', 'ignore'],
        });
        proc.on('error', reject);
        proc.on('close', (code) => {
          if (code === 0) resolve();
          else reject(new Error(`image-upgrade.sh exited with code ${code}`));
        });
      });
      logger.info(`[image-upgrade] ${slug}: image-upgrade.sh completed`);
    } catch (err) {
      logger.error(`[image-upgrade] ${slug}: ${(err as Error).message}`);
    }
  }).catch((err) => {
    if (!(err instanceof SlugBusyError)) {
      logger.error(`[image-upgrade] ${slug}: lock or background error: ${(err as Error).message}`);
    }
  });

  res.status(202).json({ started: true, mode: 'image-only' });
});

// GET /instance/:slug/upgrade/progress — Read progress.json
router.get('/instance/:slug/upgrade/progress', async (req: Request, res: Response) => {
  const entry = await getSlugEntry(param(req, 'slug'));
  const progressPath = path.join(entry.basePath, 'data', 'upgrade', 'progress.json');

  try {
    const content = await fs.readFile(progressPath, 'utf-8');
    res.json(JSON.parse(content));
  } catch {
    res.json({ phase: 0, percentage: 0, message: 'Waiting for upgrade to start...' });
  }
});

// GET /instance/:slug/upgrade/result — Read result.json
router.get('/instance/:slug/upgrade/result', async (req: Request, res: Response) => {
  const entry = await getSlugEntry(param(req, 'slug'));
  const resultPath = path.join(entry.basePath, 'data', 'upgrade', 'result.json');

  try {
    const content = await fs.readFile(resultPath, 'utf-8');
    res.json(JSON.parse(content));
  } catch {
    res.status(404).json({ error: 'NOT_FOUND', message: 'No upgrade result available' });
  }
});

export default router;
