// Capture SDK deployment — Blackmagic / AJA / Deltacast.
//
// Vendor SDKs are licensed and not redistributable, so they can't ship in
// the repo. This route lets the operator upload an SDK archive through the
// Settings UI; we extract it under /sdk/<vendor>/ (bind-mounted into mam-api)
// so the capture image build can pick the files up.
//
// Today the Dockerfile only wires Blackmagic into FFmpeg via patch_decklink.py;
// AJA and Deltacast files are staged for the next image revision but don't yet
// produce a working FFmpeg build — see the issue tracker.

import express from 'express';
import multer from 'multer';
import { promises as fs, createWriteStream } from 'fs';
import { spawn } from 'child_process';
import path from 'path';

const router = express.Router();

const SDK_ROOT = process.env.SDK_ROOT || '/sdk';

const VENDORS = {
  blackmagic: {
    name: 'Blackmagic DeckLink',
    // Header that must be present once the archive is extracted
    sentinel: 'DeckLinkAPI.h',
  },
  aja: {
    name: 'AJA NTV2',
    sentinel: 'ntv2card.h',
  },
  deltacast: {
    name: 'Deltacast VideoMaster',
    sentinel: 'VideoMasterHD_Core.h',
  },
};

const upload = multer({
  storage: multer.memoryStorage(),
  // 512 MB ceiling — Blackmagic's full SDK is ~150 MB, plenty of headroom.
  limits: { fileSize: 512 * 1024 * 1024 },
});

async function statusFor(vendor) {
  const dir = path.join(SDK_ROOT, vendor);
  try {
    const entries = await listFilesRecursive(dir);
    if (entries.length === 0) return { file_count: 0, uploaded_at: null };
    const stat = await fs.stat(dir);
    const sentinel = VENDORS[vendor].sentinel;
    const hasSentinel = entries.some(p => p.endsWith('/' + sentinel) || p === sentinel);
    return {
      file_count: entries.length,
      uploaded_at: stat.mtime.toISOString(),
      sentinel_present: hasSentinel,
    };
  } catch {
    return { file_count: 0, uploaded_at: null };
  }
}

async function listFilesRecursive(dir, base = '') {
  let out = [];
  let entries;
  try { entries = await fs.readdir(dir, { withFileTypes: true }); }
  catch { return out; }
  for (const e of entries) {
    const full = path.join(dir, e.name);
    const rel  = base ? `${base}/${e.name}` : e.name;
    if (e.isDirectory()) {
      out = out.concat(await listFilesRecursive(full, rel));
    } else if (e.isFile()) {
      out.push(rel);
    }
  }
  return out;
}

router.get('/', async (req, res, next) => {
  try {
    const out = {};
    for (const vendor of Object.keys(VENDORS)) {
      out[vendor] = await statusFor(vendor);
    }
    res.json(out);
  } catch (err) { next(err); }
});

// Safe archive entry — only basic relative paths, no parent traversal, no symlinks.
function isUnsafeEntry(rel) {
  if (!rel) return true;
  if (path.isAbsolute(rel)) return true;
  // Normalize without leaving the staging directory.
  const normalized = path.posix.normalize(rel.replace(/\\/g, '/'));
  if (normalized.startsWith('..') || normalized.includes('/../') || normalized === '..') return true;
  return false;
}

router.post('/:vendor', upload.single('archive'), async (req, res, next) => {
  try {
    const vendor = req.params.vendor;
    if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
    if (!req.file)        return res.status(400).json({ error: 'No archive uploaded (field "archive")' });

    const dir = path.join(SDK_ROOT, vendor);
    const dirReal = path.resolve(dir);

    // Wipe any previous staging so partial uploads don't leave stale headers.
    await fs.rm(dir, { recursive: true, force: true });
    await fs.mkdir(dir, { recursive: true });

    // Issue #118 — never trust the client-supplied filename. Sanitise to a
    // basename with no path separators, drop nul bytes, and force into `dir`.
    const safeName = path.basename((req.file.originalname || 'sdk.bin').replace(/\u0000/g, '')) || 'sdk.bin';
    const archivePath = path.join(dir, safeName);
    await fs.writeFile(archivePath, req.file.buffer);

    // Pick an extractor based on extension. tar handles .tar / .tar.gz / .tgz;
    // unzip handles .zip. The capture container will be built separately on
    // the host with a DeckLink/AJA/Deltacast card; this route just stages.
    const lower = safeName.toLowerCase();
    let cmd, args, listCmd, listArgs;
    if (lower.endsWith('.zip')) {
      cmd      = 'unzip'; args     = ['-q', '-o', archivePath, '-d', dir];
      listCmd  = 'unzip'; listArgs = ['-Z1', archivePath];
    } else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
      // --absolute-names=no would be ideal, but isn't portable. Block via
      // post-extract scan + reject any entry with a parent-traversal path.
      cmd      = 'tar'; args     = ['-xf', archivePath, '-C', dir];
      listCmd  = 'tar'; listArgs = ['-tf', archivePath];
    } else {
      return res.status(400).json({ error: 'Unsupported archive format — use .zip, .tar.gz, .tgz, or .tar' });
    }

    // Pre-flight: list entries and reject the upload if any escape the dir
    // (zip-slip / tar-slip). Cheaper than extracting then deleting.
    const entries = await new Promise((resolve, reject) => {
      const child = spawn(listCmd, listArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
      let stdout = '', stderr = '';
      child.stdout.on('data', d => { stdout += d.toString(); });
      child.stderr.on('data', d => { stderr += d.toString(); });
      child.on('error', reject);
      child.on('exit', code => {
        if (code === 0) resolve(stdout.split('\n').map(s => s.trim()).filter(Boolean));
        else reject(new Error(`${listCmd} listing exited ${code}: ${stderr.slice(0, 500)}`));
      });
    });

    const bad = entries.find(isUnsafeEntry);
    if (bad) {
      await fs.unlink(archivePath).catch(() => {});
      return res.status(400).json({ error: `Refusing archive with unsafe entry: ${bad}` });
    }

    await new Promise((resolve, reject) => {
      const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
      let stderr = '';
      child.stderr.on('data', d => { stderr += d.toString(); });
      child.on('error', reject);
      child.on('exit', code => {
        if (code === 0) resolve();
        else reject(new Error(`${cmd} exited ${code}: ${stderr.slice(0, 500)}`));
      });
    });

    // Defense-in-depth: walk the staged tree and remove anything that's not a
    // regular file or directory (symlinks/device nodes can still escape).
    async function walkAndSanitize(p) {
      const entries = await fs.readdir(p, { withFileTypes: true });
      for (const e of entries) {
        const full = path.join(p, e.name);
        const real = await fs.realpath(full).catch(() => null);
        if (!real || !real.startsWith(dirReal + path.sep) && real !== dirReal) {
          await fs.rm(full, { recursive: true, force: true });
          continue;
        }
        if (e.isSymbolicLink() || (!e.isFile() && !e.isDirectory())) {
          await fs.rm(full, { recursive: true, force: true });
          continue;
        }
        if (e.isDirectory()) await walkAndSanitize(full);
      }
    }
    await walkAndSanitize(dir);

    // Best-effort: remove the archive after a successful extract so we only
    // keep the unpacked headers/.so files on disk.
    await fs.unlink(archivePath).catch(() => {});

    const status = await statusFor(vendor);
    res.json({ message: VENDORS[vendor].name + ' SDK staged.', status });
  } catch (err) {
    console.error('[sdk] upload failed:', err);
    res.status(500).json({ error: err.message });
  }
});

router.delete('/:vendor', async (req, res, next) => {
  try {
    const vendor = req.params.vendor;
    if (!VENDORS[vendor]) return res.status(400).json({ error: 'Unknown vendor: ' + vendor });
    const dir = path.join(SDK_ROOT, vendor);
    await fs.rm(dir, { recursive: true, force: true });
    res.json({ message: VENDORS[vendor].name + ' SDK cleared.' });
  } catch (err) { next(err); }
});

export default router;
