// services/mam-api/src/routes/sequences.js
import express from 'express';
import pool    from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js';
import { validateUuid } from '../middleware/errors.js';
import { assertProjectAccess } from '../auth/authz.js';
import { Queue } from 'bullmq';

const parseRedisUrl = (url) => {
  try {
    const parsed = new URL(url);
    return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
  } catch {
    return { host: 'localhost', port: 6379 };
  }
};

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

const router = express.Router();

// Scope every /:id sequence route to its project: validate the UUID, resolve
// project_id, assert the 'view' baseline; mutators escalate via requireSequenceEdit.
router.param('id', async (req, res, next) => {
  validateUuid('id')(req, res, () => {});
  if (res.headersSent) return;
  try {
    const { rows } = await pool.query('SELECT project_id FROM sequences WHERE id = $1', [req.params.id]);
    if (rows.length === 0) return res.status(404).json({ error: 'Sequence not found' });
    req.sequenceProjectId = rows[0].project_id;
    await assertProjectAccess(req.user, req.sequenceProjectId, 'view');
    next();
  } catch (err) { next(err); }
});

async function requireSequenceEdit(req, res, next) {
  try {
    await assertProjectAccess(req.user, req.sequenceProjectId, 'edit');
    next();
  } catch (err) { next(err); }
}

// ── Row mapper ────────────────────────────────────────────────────────────────
// node-postgres returns NUMERIC columns as strings.  Coerce frame_rate to a
// JS float before sending any sequence object to clients.

function mapSeq(row) {
  if (!row) return row;
  return { ...row, frame_rate: parseFloat(row.frame_rate) || 59.94 };
}

// ── Timecode helpers ──────────────────────────────────────────────────────────
//
// generateEDL emits CMX3600 timecode using the sequence's frame_rate.
//
//   29.97 fps  → NTSC drop-frame  (30 NOM, drop 2/min except every 10th)  → ";"
//   59.94 fps  → double-NTSC DF  (60 NOM, drop 4/min except every 10th)  → ";"
//   all others → non-drop integer (24/25/30/50/60 …)                      → ":"
//

function pad2(n) { return String(Math.floor(n)).padStart(2, '0'); }

function framesToTC(totalFrames, fps) {
  fps = parseFloat(fps) || 59.94;
  const fc = Math.max(0, Math.round(totalFrames));

  // 29.97 DF ─ drop 2 frames per minute except every 10th
  if (Math.abs(fps - 29.97) < 0.02) {
    const NOM   = 30, DROP = 2;
    const FPM   = NOM * 60 - DROP;          // 1798
    const FP10M = FPM * 10 + DROP;          // 17982
    const FPH   = FP10M * 6;               // 107892
    const h   = Math.floor(fc / FPH);
    let   rem = fc % FPH;
    const tm  = Math.floor(rem / FP10M);
    rem       = rem % FP10M;
    let m, ss, ff;
    if (rem < NOM * 60) {
      m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM;
    } else {
      rem -= NOM * 60;
      m    = Math.floor(rem / FPM) + 1;
      const adj = (rem % FPM) + DROP;
      ss = Math.floor(adj / NOM); ff = adj % NOM;
    }
    return `${pad2(h)}:${pad2(tm * 10 + m)}:${pad2(ss)};${pad2(ff)}`;
  }

  // 59.94 DF ─ drop 4 frames per minute except every 10th
  if (Math.abs(fps - 59.94) < 0.02) {
    const NOM   = 60, DROP = 4;
    const FPM   = NOM * 60 - DROP;          // 3596
    const FP10M = FPM * 10 + DROP;          // 35964
    const FPH   = FP10M * 6;               // 215784
    const h   = Math.floor(fc / FPH);
    let   rem = fc % FPH;
    const tm  = Math.floor(rem / FP10M);
    rem       = rem % FP10M;
    let m, ss, ff;
    if (rem < NOM * 60) {
      m = 0; ss = Math.floor(rem / NOM); ff = rem % NOM;
    } else {
      rem -= NOM * 60;
      m    = Math.floor(rem / FPM) + 1;
      const adj = (rem % FPM) + DROP;
      ss = Math.floor(adj / NOM); ff = adj % NOM;
    }
    return `${pad2(h)}:${pad2(tm * 10 + m)}:${pad2(ss)};${pad2(ff)}`;
  }

  // Non-drop frame (24, 23.976→24, 25, 30, 50, 60 …) ─ colon separator
  const nomFps   = Math.round(fps);
  const ff       = fc % nomFps;
  const totalSec = Math.floor(fc / nomFps);
  const ss       = totalSec % 60;
  const mm       = Math.floor(totalSec / 60) % 60;
  const hh       = Math.floor(totalSec / 3600);
  return `${pad2(hh)}:${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`;
}

function generateEDL(seqName, clips, fps) {
  fps = parseFloat(fps) || 59.94;
  const lines = [`TITLE: ${seqName}`, ''];
  clips.forEach((c, i) => {
    const num    = String(i + 1).padStart(3, '0');
    const reel   = (c.filename || 'UNKNOWN')
      .replace(/\.[^.]+$/, '')   // strip extension
      .replace(/[^A-Za-z0-9_]/g, '_')
      .toUpperCase()
      .substring(0, 32)
      .padEnd(8);
    const srcIn  = framesToTC(c.source_in_frames,   fps);
    const srcOut = framesToTC(c.source_out_frames,  fps);
    const recIn  = framesToTC(c.timeline_in_frames,  fps);
    const recOut = framesToTC(c.timeline_out_frames, fps);
    lines.push(`${num}  ${reel}  V  C  ${srcIn} ${srcOut} ${recIn} ${recOut}`);
  });
  return lines.join('\n');
}

// ── GET / – list sequences for a project ─────────────────────────────────────
router.get('/', async (req, res, next) => {
  try {
    const { project_id } = req.query;
    if (!project_id) return res.status(400).json({ error: 'project_id is required' });
    await assertProjectAccess(req.user, project_id, 'view');
    const r = await pool.query(
      `SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
      [project_id]
    );
    res.json(r.rows.map(mapSeq));
  } catch (e) { next(e); }
});

// ── POST / – create sequence ──────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
  try {
    const {
      project_id,
      name       = 'Sequence 1',
      frame_rate = 59.94,
      width      = 1920,
      height     = 1080,
    } = req.body;
    if (!project_id) return res.status(400).json({ error: 'project_id is required' });
    await assertProjectAccess(req.user, project_id, 'edit');
    const r = await pool.query(
      `INSERT INTO sequences (project_id, name, frame_rate, width, height)
       VALUES ($1, $2, $3, $4, $5) RETURNING *`,
      [project_id, name, frame_rate, width, height]
    );
    res.status(201).json(mapSeq(r.rows[0]));
  } catch (e) { next(e); }
});

// ── GET /:id – sequence + all clips joined with asset data ───────────────────
router.get('/:id', async (req, res, next) => {
  try {
    const seqR = await pool.query(
      `SELECT * FROM sequences WHERE id = $1`,
      [req.params.id]
    );
    if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });

    const clipsR = await pool.query(
      `SELECT sc.*,
              a.display_name, a.filename, a.fps, a.duration_ms,
              a.proxy_s3_key, a.thumbnail_s3_key
       FROM sequence_clips sc
       JOIN assets a ON a.id = sc.asset_id
       WHERE sc.sequence_id = $1
       ORDER BY sc.track, sc.timeline_in_frames`,
      [req.params.id]
    );

    // Attach signed stream URLs (best-effort; missing proxy → streamUrl: null)
    const clips = await Promise.all(
      clipsR.rows.map(async (clip) => {
        let streamUrl = null;
        if (clip.proxy_s3_key) {
          try { streamUrl = await getSignedUrlForObject(clip.proxy_s3_key); } catch (_) {}
        }
        return { ...clip, streamUrl };
      })
    );

    res.json({ ...mapSeq(seqR.rows[0]), clips });
  } catch (e) { next(e); }
});

// ── PUT /:id – update sequence metadata ──────────────────────────────────────
router.put('/:id', requireSequenceEdit, async (req, res, next) => {
  try {
    const { name, frame_rate, width, height } = req.body;
    const updates = [];
    const params  = [];
    let   n       = 1;
    if (name       !== undefined) { updates.push(`name = $${n++}`);       params.push(name); }
    if (frame_rate !== undefined) { updates.push(`frame_rate = $${n++}`); params.push(frame_rate); }
    if (width      !== undefined) { updates.push(`width = $${n++}`);      params.push(width); }
    if (height     !== undefined) { updates.push(`height = $${n++}`);     params.push(height); }
    if (!updates.length) return res.status(400).json({ error: 'No fields to update' });
    updates.push('updated_at = NOW()');
    params.push(req.params.id);
    const r = await pool.query(
      `UPDATE sequences SET ${updates.join(', ')} WHERE id = $${n} RETURNING *`,
      params
    );
    if (!r.rows.length) return res.status(404).json({ error: 'Sequence not found' });
    res.json(mapSeq(r.rows[0]));
  } catch (e) { next(e); }
});

// ── DELETE /:id ───────────────────────────────────────────────────────────────
router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
  try {
    const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
    if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
    res.json({ ok: true });
  } catch (e) { next(e); }
});

// ── PUT /:id/clips – full replace of clip array (single transaction) ──────────
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
  const clips = Array.isArray(req.body) ? req.body : [];
  let client;
  try {
    // Verify sequence exists first (before acquiring transaction client).
    const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
    if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });

    for (const c of clips) {
      if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
      if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
          !Number.isFinite(Number(c.source_in_frames))   || !Number.isFinite(Number(c.source_out_frames))) {
        return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
      }
      if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
        return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
      }
    }

    // Every referenced asset must belong to THIS sequence's project. Without this,
    // a user with edit on the sequence could splice in assets from a project they
    // can't access — and GET /:id would then hand back those assets' names and
    // signed proxy URLs (cross-project leak).
    const assetIds = [...new Set(clips.map(c => c.asset_id))];
    if (assetIds.length) {
      const owning = await pool.query(
        `SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`,
        [assetIds, req.sequenceProjectId]
      );
      if (owning.rows.length !== assetIds.length) {
        return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' });
      }
    }

    client = await pool.connect();
    await client.query('BEGIN');
    await client.query(
      `DELETE FROM sequence_clips WHERE sequence_id = $1`,
      [req.params.id]
    );
    for (const c of clips) {
      await client.query(
        `INSERT INTO sequence_clips
           (sequence_id, asset_id, track,
            timeline_in_frames, timeline_out_frames,
            source_in_frames,   source_out_frames)
         VALUES ($1, $2, $3, $4, $5, $6, $7)`,
        [
          req.params.id, c.asset_id, c.track,
          c.timeline_in_frames, c.timeline_out_frames,
          c.source_in_frames,   c.source_out_frames,
        ]
      );
    }
    await client.query(
      `UPDATE sequences SET updated_at = NOW() WHERE id = $1`,
      [req.params.id]
    );
    await client.query('COMMIT');
    res.json({ ok: true, count: clips.length });
  } catch (e) {
    // client is only set once we've connected; a failure in the pre-transaction
    // queries (existence/validation/ownership) has no transaction to roll back.
    if (client) await client.query('ROLLBACK').catch(() => {});
    next(e);
  } finally {
    if (client) client.release();
  }
});

// ── POST /:id/export/edl – download CMX3600 EDL ──────────────────────────────
router.post('/:id/export/edl', async (req, res, next) => {
  try {
    const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
    if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
    const seq = mapSeq(seqR.rows[0]);

    // Export V1 clips only (primary video track) sorted by position
    const clipsR = await pool.query(
      `SELECT sc.*, a.filename
       FROM sequence_clips sc
       JOIN assets a ON a.id = sc.asset_id
       WHERE sc.sequence_id = $1 AND sc.track = 0
       ORDER BY sc.timeline_in_frames`,
      [req.params.id]
    );

    const edl      = generateEDL(seq.name, clipsR.rows, seq.frame_rate);
    const filename = `${seq.name.replace(/[^a-z0-9]/gi, '_')}.edl`;
    res.setHeader('Content-Type',        'text/plain; charset=utf-8');
    res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
    res.send(edl);
  } catch (e) { next(e); }
});

// ── POST /:id/conform – conform sequence via FCP XML ─────────────────────────
// Accepts FCP XML content and encode settings from the Premiere plugin,
// queues a conform job in BullMQ, and returns the job ID for polling.
router.post('/:id/conform', requireSequenceEdit, async (req, res, next) => {
  try {
    const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
    if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
    const seq = mapSeq(seqR.rows[0]);

    const { fcp_xml, codec = 'h264', quality = 'high', resolution = 'match', audio = 'include' } = req.body;

    if (!fcp_xml) {
      return res.status(400).json({ error: 'fcp_xml is required' });
    }

    const bullJob = await conformQueue.add('conform-task', {
      fcpXml: fcp_xml,
      sequenceId: req.params.id,
      // The worker INSERTs the rendered output into the `assets` table at the
      // end of the pipeline; project_id is NOT NULL on that table, so without
      // this the conform finished successfully but failed at the very last
      // step. Sequences live under projects, so the natural target for the
      // rendered output is the sequence's own project.
      projectId: seq.project_id,
      sequenceName: seq.name,
      frameRate: seq.frame_rate,
      width: seq.width,
      height: seq.height,
      codec,
      quality,
      resolution,
      audio,
    });

    res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
  } catch (err) {
    next(err);
  }
});

export default router;
