// Promotion worker — scans the growing-files SMB landing zone for stable
// captures (no mtime change for N seconds) and uploads them to S3, flipping
// the matching asset's status from 'growing' to 'ready' so the Premiere
// panel can relink to the proxy/hi-res URLs.
//
// Why a poll loop and not chokidar: NFS/SMB mounts don't reliably surface
// inotify events through the kernel; mtime polling is the boring-but-works
// answer for fairness across all storage backends.
import { readdir, stat, unlink, mkdir, writeFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { join, relative, basename } from 'node:path';
import { createReadStream } from 'node:fs';
import { Queue } from 'bullmq';
import { query } from '../db/client.js';
import { uploadStreamToS3 } from '../s3/client.js';

const GROWING_PATH = process.env.GROWING_PATH || '/growing';
const S3_BUCKET    = process.env.S3_BUCKET    || 'wild-dragon';
const POLL_MS      = 5000;
const SMB_CREDS_FILE = '/run/promotion-smb-creds';

// Normalize a Windows / smb:// share path to the //host/share UNC that
// mount.cifs accepts (mirrors services/capture/src/capture-manager.js).
function toUncShare(raw) {
  if (!raw) return '';
  let s = String(raw).trim().replace(/\\/g, '/');
  s = s.replace(/^smb:\/\//i, '//');
  if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, '');
  return s;
}

function isMounted(path) {
  try { execFileSync('mountpoint', ['-q', path]); return true; }
  catch { return false; }
}

// Mount the growing-files CIFS share at GROWING_PATH so the promotion scanner
// sees the SAME files the capture sidecar writes on the remote node. Without
// this the worker was watching a LOCAL empty /growing and never promoted any
// growing capture — the master never reached S3 and the only proxy that fired
// was the bogus one from capture's finalize call (against a key that doesn't
// exist) → "unable to open the file on disk". Best-effort + idempotent.
async function ensureGrowingShareMounted() {
  const r = await query(
    `SELECT key, value FROM settings WHERE key = ANY($1)`,
    [['growing_smb_mount', 'growing_smb_username', 'growing_smb_password', 'growing_smb_vers']]
  ).catch(() => ({ rows: [] }));
  const cfg = {};
  for (const { key, value } of r.rows) cfg[key] = value;

  const share = toUncShare(cfg.growing_smb_mount || '');
  if (!share) {
    console.log('[promotion] no growing_smb_mount configured — using local GROWING_PATH');
    return;
  }
  try {
    if (isMounted(GROWING_PATH)) {
      console.log('[promotion] growing share already mounted at', GROWING_PATH);
      return;
    }
    await mkdir(GROWING_PATH, { recursive: true }).catch(() => {});
    await writeFile(
      SMB_CREDS_FILE,
      `username=${cfg.growing_smb_username || ''}\npassword=${cfg.growing_smb_password || ''}\n`,
      { mode: 0o600 }
    );
    const opts = [
      `credentials=${SMB_CREDS_FILE}`,
      'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
      `vers=${cfg.growing_smb_vers || '3.0'}`,
    ].join(',');
    execFileSync('mount', ['-t', 'cifs', share, GROWING_PATH, '-o', opts],
      { stdio: ['ignore', 'ignore', 'pipe'] });
    console.log('[promotion] mounted CIFS growing share', share, '->', GROWING_PATH);
  } catch (err) {
    const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
    console.error('[promotion] CIFS mount failed (promotion will not see growing files):', stderr);
  }
}

let inflight = new Set();
let idleThresholdMs = 8000;

// BUG FIX #3: Create a single module-level proxyQueue instead of spinning up
// a new Queue connection (and closing it) on every promoted file. Repeatedly
// creating and closing BullMQ Queues hammers Redis with connect/disconnect
// cycles and leaks connections when the close() call races with the add().
const parseRedisUrl = (url) => {
  const parsed = new URL(url);
  return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
};

const proxyQueue = new Queue('proxy', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

// Interval handles — stored so startPromotionWorker() can expose a shutdown
// function for the SIGTERM handler in index.js (BUG FIX #4).
let scanInterval = null;
let thresholdInterval = null;

async function loadIdleThreshold() {
  try {
    const r = await query(
      `SELECT value FROM settings WHERE key = 'growing_promote_after_seconds'`
    );
    const sec = parseInt(r.rows[0]?.value, 10);
    if (sec > 0) idleThresholdMs = sec * 1000;
  } catch (_) { /* table not migrated yet — keep default */ }
}

async function* walk(dir) {
  let entries = [];
  try { entries = await readdir(dir, { withFileTypes: true }); }
  catch (_) { return; }
  for (const e of entries) {
    const full = join(dir, e.name);
    if (e.isDirectory()) yield* walk(full);
    else if (e.isFile()) yield full;
  }
}

async function promote(filePath) {
  if (inflight.has(filePath)) return;
  inflight.add(filePath);
  try {
    // Reconstruct the S3 key from the relative path under GROWING_PATH.
    // Capture writes `${GROWING_PATH}/${projectId}/${clipName}.${ext}`, which
    // mirrors `projects/${projectId}/masters/${clipName}.${ext}` in S3.
    const rel = relative(GROWING_PATH, filePath);          // <projectId>/<clip>.<ext>
    const [projectId, fileName] = rel.split('/', 2);
    if (!projectId || !fileName) return;
    const s3Key = `projects/${projectId}/masters/${fileName}`;

    // Find the matching live asset by display_name = clipName.
    const clipName = basename(fileName, '.' + fileName.split('.').pop());
    const r = await query(
      `SELECT id, status FROM assets
       WHERE project_id = $1 AND display_name = $2
       ORDER BY created_at DESC LIMIT 1`,
      [projectId, clipName]
    );
    if (r.rows.length === 0) {
      console.warn(`[promotion] no asset row for ${rel} — skipping`);
      return;
    }
    const asset = r.rows[0];

    // CRITICAL: do not promote while the recorder is STILL RECORDING this
    // session. The mtime-idle heuristic is unreliable over CIFS (attribute
    // caching makes an actively-growing MXF look "stable"), which caused the
    // worker to grab a live file mid-record (~15s in), upload it, flip the
    // asset to 'ready', and unlink it — "a worker is stealing the file". The
    // growing asset's display_name IS the recorder's current_session_id, so
    // gate on the recorder's live status: only promote once recording stopped.
    const recActive = await query(
      `SELECT 1 FROM recorders
        WHERE current_session_id = $1 AND status = 'recording' LIMIT 1`,
      [clipName]
    );
    if (recActive.rows.length > 0) {
      // Still recording — leave the growing file in place for the editor.
      return;
    }

    const st = await stat(filePath);
    console.log(`[promotion] uploading ${rel} (${st.size} bytes) -> s3://${S3_BUCKET}/${s3Key}`);
    await uploadStreamToS3(S3_BUCKET, s3Key, createReadStream(filePath));

    await query(
      `UPDATE assets
       SET original_s3_key = $1,
           file_size       = $2,
           status          = 'ready',
           updated_at      = NOW()
       WHERE id = $3`,
      [s3Key, st.size, asset.id]
    );

    // BUG FIX #3 (cont): Use the module-level proxyQueue singleton instead of
    // creating a new Queue + closing it inside every promote() call.
    await proxyQueue.add('generate', {
      assetId:  asset.id,
      inputKey: s3Key,
      outputKey: `proxies/${asset.id}.mp4`,
    });

    await unlink(filePath).catch(err => {
      console.warn(`[promotion] could not unlink ${rel}: ${err.message}`);
    });
    console.log(`[promotion] asset ${asset.id} promoted, proxy queued, local file removed`);
  } catch (err) {
    console.error('[promotion] failed for', filePath, err);
  } finally {
    inflight.delete(filePath);
  }
}

async function scan() {
  const now = Date.now();
  for await (const file of walk(GROWING_PATH)) {
    if (inflight.has(file)) continue;
    let st;
    try { st = await stat(file); } catch (_) { continue; }
    if (now - st.mtimeMs >= idleThresholdMs && st.size > 0) {
      // BUG FIX #3 (cont): await promote() so that unhandled promise rejections
      // don't escape the scan loop. promote() already catches internally, so
      // this is belt-and-suspenders against future changes.
      await promote(file);
    }
  }
}

// BUG FIX #4: Return a shutdown function so index.js can clear the intervals
// and close the queue connection during SIGTERM.
export function startPromotionWorker() {
  loadIdleThreshold();
  // Mount the SMB landing zone before the first scan so we watch the SAME share
  // the capture sidecars write to (best-effort; falls back to local GROWING_PATH).
  ensureGrowingShareMounted().catch((e) =>
    console.error('[promotion] mount bootstrap failed:', e.message));
  thresholdInterval = setInterval(loadIdleThreshold, 60_000);
  scanInterval      = setInterval(scan, POLL_MS);
  console.log(`[promotion] watching ${GROWING_PATH} (idle threshold ${idleThresholdMs}ms)`);

  return async function stopPromotionWorker() {
    if (scanInterval)      clearInterval(scanInterval);
    if (thresholdInterval) clearInterval(thresholdInterval);
    await proxyQueue.close().catch(() => {});
    console.log('[promotion] worker stopped');
  };
}
