// Static file serving + authenticated recording/transcript downloads.
// handleGet() is the fallback for any GET that didn't match an API route.
const fs = require('fs');
const path = require('path');
const R = require('./repos');
const { json } = require('./lib');
const { currentUser } = require('./session');
const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR, DOWNLOADS_DIR } = require('./config');
const media = require('./media');

const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json', '.webmanifest': 'application/manifest+json' };

// Authorize an attachment id: the uploader, a member of the group using it as an avatar, or a participant of
// ANY message carrying it (the "any" covers forwarded attachments, which reuse the same id). Returns the row.
async function authAttachmentRaw(id, u) {
  const a = await R.attachments.byId(id);
  if (!a || a.team_id !== u.team_id) return null;
  const avatarGroup = await R.conversations.byAvatar(id);
  const carriers = await R.messages.allByAttachment(id);
  let ok = a.uploader_id === u.id || (avatarGroup && await R.conversations.isMember(avatarGroup.id, u.id));
  if (!ok) {
    // A .some() predicate can't await, so walk the carriers explicitly.
    for (const msg of carriers) {
      const carried = msg.conversation_id
        ? await R.conversations.isMember(msg.conversation_id, u.id)
        : (msg.sender_id === u.id || msg.recipient_id === u.id);
      if (carried) { ok = true; break; }
    }
  }
  return ok ? a : null;
}
// Video playback fires MANY /files Range requests, and authAttachmentRaw scans messages by attachment_id
// each time — that per-chunk scan is what made playback stutter ("buffers and plays…"). Cache the resolved
// decision per user+attachment for 60s so range requests after the first are ~free. Bounded to keep memory flat.
const _attAuth = new Map();
async function authAttachment(id, u) {
  const key = u.id + ':' + id, now = Date.now();
  const hit = _attAuth.get(key);
  if (hit && hit.exp > now) return hit.a;
  const a = await authAttachmentRaw(id, u);
  if (_attAuth.size > 4000) _attAuth.clear();
  _attAuth.set(key, { a, exp: now + 60000 });
  return a;
}

// Serve a file with HTTP Range support (206 Partial Content) — required for reliable iOS video
// streaming and for seeking anywhere other than the start.
function sendRanged(req, res, fp, headers) {
  return fs.stat(fp, (err, st) => {
    if (err) return json(res, 404, { error: 'not found' });
    const total = st.size;
    const base = Object.assign({ 'Accept-Ranges': 'bytes' }, headers);
    const mm = req.headers.range && /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
    if (mm) {
      let start = mm[1] ? parseInt(mm[1], 10) : 0;
      let end = mm[2] ? parseInt(mm[2], 10) : total - 1;
      if (isNaN(start)) start = 0;
      if (isNaN(end) || end >= total) end = total - 1;
      if (start > end || start >= total) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); return res.end(); }
      res.writeHead(206, Object.assign({}, base, { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total, 'Content-Length': (end - start + 1) }));
      const rs = fs.createReadStream(fp, { start, end });
      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
      return rs.pipe(res);
    }
    res.writeHead(200, Object.assign({}, base, { 'Content-Length': total }));
    const rs = fs.createReadStream(fp);
    rs.on('error', () => { try { res.destroy(); } catch (e) {} });
    rs.pipe(res);
  });
}

function serveStatic(req, res) {
  let p = req.url.split('?')[0];
  if (p === '/') p = '/index.html';
  if (p === '/home') p = '/home.html';
  // Console was replaced by Dashboard; keep the old path working.
  if (p === '/console' || p === '/dashboard') p = '/dashboard.html';
  if (p === '/share') p = '/share.html';
  if (p === '/connect') p = '/connect.html';
  const fp = path.join(PUBLIC_DIR, path.normalize(p));
  if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
  // ETag + revalidation: the browser keeps the file cached and we answer repeat loads with a
  // tiny 304 (no re-download) when nothing changed — fast reloads, but always fresh on edits.
  fs.stat(fp, (serr, st) => {
    if (serr || !st.isFile()) return json(res, 404, { error: 'not found' });
    const ext = path.extname(fp);
    const ct = MIME[ext] || 'application/octet-stream';
    // HTML entry pages are NEVER cached (no-store) so a deploy reaches every browser on the
    // next load — no hard-refresh needed. Versioned assets (e.g. icons.js?v=) still revalidate
    // cheaply via ETag/304.
    const isHtml = ext === '.html';
    const etag = '"' + st.size.toString(16) + '-' + Math.round(st.mtimeMs).toString(16) + '"';
    if (!isHtml && req.headers['if-none-match'] === etag) {
      res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' });
      return res.end();
    }
    fs.readFile(fp, (err, data) => {
      if (err) return json(res, 404, { error: 'not found' });
      const headers = { 'Content-Type': ct, 'Content-Length': st.size, 'Cache-Control': isHtml ? 'no-store, must-revalidate' : 'no-cache' };
      if (!isHtml) headers.ETag = etag;
      res.writeHead(200, headers);
      res.end(data);
    });
  });
}

// GET fallback: authenticated transcript/recording downloads, else static files.
async function handleGet(req, res) {
  const pathOnly = req.url.split('?')[0];
  // Stable "latest Windows installer" link (used by the site's Download button). Reads the
  // electron-updater manifest and redirects to the current versioned .exe.
  if (pathOnly === '/download/windows') {
    try {
      const yml = fs.readFileSync(path.join(DOWNLOADS_DIR, 'latest.yml'), 'utf8');
      const m = yml.match(/^path:\s*(.+)$/m);
      const file = m ? m[1].trim().replace(/^['"]|['"]$/g, '') : null;
      if (file) { res.writeHead(302, { Location: '/downloads/' + encodeURIComponent(file), 'Cache-Control': 'no-cache' }); return res.end(); }
    } catch (_) {}
    return json(res, 404, { error: 'No Windows build is available yet.' });
  }
  // Public download feed: installers (.exe), the update manifest (latest.yml) and .blockmap.
  // Range support so large installers resume and electron-updater can do differential updates.
  if (pathOnly.startsWith('/downloads/')) {
    const name = path.basename(decodeURIComponent(pathOnly));
    if (!name || name.startsWith('.')) return json(res, 404, { error: 'not found' });
    const fp = path.join(DOWNLOADS_DIR, name);
    if (!fp.startsWith(DOWNLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
    return fs.stat(fp, (err, st) => {
      if (err || !st.isFile()) return json(res, 404, { error: 'not found' });
      const ext = path.extname(name).toLowerCase();
      const ct = ext === '.yml' ? 'text/yaml; charset=utf-8' : ext === '.dmg' ? 'application/x-apple-diskimage' : 'application/octet-stream';
      const headers = { 'Content-Type': ct, 'Accept-Ranges': 'bytes', 'Cache-Control': ext === '.yml' ? 'no-cache' : 'public, max-age=86400' };
      if (ext === '.exe' || ext === '.dmg' || ext === '.appimage') headers['Content-Disposition'] = 'attachment; filename="' + name + '"';
      const range = req.headers.range;
      if (range && /^bytes=\d*-\d*$/.test(range)) {
        const [s, e] = range.replace('bytes=', '').split('-');
        const start = s ? parseInt(s, 10) : 0; const end = e ? parseInt(e, 10) : st.size - 1;
        if (start >= st.size || end >= st.size || start > end) { res.writeHead(416, { 'Content-Range': 'bytes */' + st.size }); return res.end(); }
        headers['Content-Range'] = 'bytes ' + start + '-' + end + '/' + st.size; headers['Content-Length'] = end - start + 1;
        res.writeHead(206, headers);
        const rs = fs.createReadStream(fp, { start, end }); rs.on('error', () => { try { res.destroy(); } catch (_) {} }); return rs.pipe(res);
      }
      headers['Content-Length'] = st.size;
      res.writeHead(200, headers);
      const rs = fs.createReadStream(fp); rs.on('error', () => { try { res.destroy(); } catch (_) {} }); rs.pipe(res);
    });
  }
  if (pathOnly.startsWith('/transcripts/')) {
    const u = await currentUser(req);
    if (!u) return json(res, 401, { error: 'unauthorized' });
    const name = path.basename(decodeURIComponent(pathOnly));
    const sid = name.replace(/\.txt$/i, '');
    const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
    if (!row || !row.transcript) return json(res, 404, { error: 'not found' });
    const fp = path.join(TRANS_DIR, row.transcript);
    if (!fp.startsWith(TRANS_DIR)) return json(res, 403, { error: 'forbidden' });
    return fs.stat(fp, (err, st) => {
      if (err) return json(res, 404, { error: 'not found' });
      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="transcript-' + sid + '.txt"', 'Cache-Control': 'no-store' });
      const rs = fs.createReadStream(fp);
      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
      rs.pipe(res);
    });
  }
  if (pathOnly.startsWith('/recordings/')) {
    const u = await currentUser(req);
    if (!u) return json(res, 401, { error: 'unauthorized' });
    const name = path.basename(decodeURIComponent(pathOnly));
    const sid = name.replace(/\.(webm|mp4)$/i, '');
    const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
    if (!row || !row.recording) return json(res, 404, { error: 'not found' });
    const fp = path.join(REC_DIR, row.recording);
    if (!fp.startsWith(REC_DIR)) return json(res, 403, { error: 'forbidden' });
    const ext = path.extname(row.recording).toLowerCase() === '.mp4' ? 'mp4' : 'webm';
    const ctype = ext === 'mp4' ? 'video/mp4' : 'video/webm';
    return fs.stat(fp, (err, st) => {
      if (err) return json(res, 404, { error: 'not found' });
      res.writeHead(200, { 'Content-Type': ctype, 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="session-' + sid + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
      const rs = fs.createReadStream(fp);
      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
      rs.pipe(res);
    });
  }
  // Meeting recordings & transcripts (/mrec/<id>). Visible to the creator, group members, or those
  // who can see the scheduled meeting it belongs to.
  if (pathOnly.startsWith('/mrec/')) {
    const u = await currentUser(req);
    if (!u) return json(res, 401, { error: 'unauthorized' });
    const id = path.basename(decodeURIComponent(pathOnly));
    const r = await R.recordings.byId(id);
    if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' });
    let allowed = r.created_by === u.id;
    if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner
    else {
      if (!allowed && r.group_id) allowed = await R.conversations.isMember(r.group_id, u.id);
      if (!allowed && r.meeting_id) { const s = await R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
    }
    if (!allowed) return json(res, 403, { error: 'forbidden' });
    const isVideo = r.kind === 'video';
    const dir = isVideo ? REC_DIR : TRANS_DIR;
    const fp = path.join(dir, r.file);
    if (!fp.startsWith(dir)) return json(res, 403, { error: 'forbidden' });
    const ext = isVideo ? 'webm' : 'txt';
    const fname = String(r.title || 'meeting').replace(/[^a-z0-9 _-]/gi, '').trim().slice(0, 40) || 'meeting';
    return fs.stat(fp, (err, st) => {
      if (err) return json(res, 404, { error: 'not found' });
      res.writeHead(200, { 'Content-Type': r.mime || (isVideo ? 'video/webm' : 'text/plain; charset=utf-8'), 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="' + fname + '-' + (isVideo ? 'recording' : 'transcript') + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' });
      const rs = fs.createReadStream(fp);
      rs.on('error', () => { try { res.destroy(); } catch (e) {} });
      rs.pipe(res);
    });
  }
  // Video POSTER thumbnail — first frame extracted with ffmpeg, cached next to the file. Cosmetic: if ffmpeg
  // is missing or fails we 404 and the <video> just falls back to its own (black) poster.
  if (pathOnly.startsWith('/thumbs/')) {
    const u = await currentUser(req);
    if (!u) return json(res, 401, { error: 'unauthorized' });
    const id = path.basename(decodeURIComponent(pathOnly));
    const a = await authAttachment(id, u);
    if (!a) return json(res, 404, { error: 'not found' });
    if (!/^video\//.test(a.mime || '')) return json(res, 404, { error: 'not a video' });
    const src = path.join(UPLOADS_DIR, id);
    const thumb = path.join(UPLOADS_DIR, id + '.thumb.jpg');
    if (!src.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
    const send = () => fs.stat(thumb, (e, st) => {
      if (e) return json(res, 404, { error: 'no thumbnail' });
      res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': st.size, 'Cache-Control': 'private, max-age=604800' });
      const rs = fs.createReadStream(thumb); rs.on('error', () => { try { res.destroy(); } catch (e2) {} }); rs.pipe(res);
    });
    if (fs.existsSync(thumb)) return send();
    // Write to a temp then rename — media.js may pre-generate the same poster at upload, and two writers
    // to the same path could otherwise serve a half-written JPEG.
    const tmp = thumb + '.req.part';
    return require('child_process').execFile('ffmpeg', ['-y', '-ss', '0.5', '-i', src, '-frames:v', '1', '-vf', 'scale=480:-2', '-q:v', '4', tmp], { timeout: 15000 }, (err) => {
      if (err) { try { fs.unlinkSync(tmp); } catch (_) {} return json(res, 404, { error: 'thumbnail unavailable' }); }
      try { if (!fs.existsSync(thumb)) fs.renameSync(tmp, thumb); else fs.unlinkSync(tmp); } catch (_) {}
      send();
    });
  }
  // PLAYBACK source for in-chat videos. Prefers the capped/faststart rendition built by media.js; falls
  // back to the original bytes while that is still transcoding, so a video is never unplayable. The
  // download button keeps pointing at /files, which always serves the untouched original.
  if (pathOnly.startsWith('/stream/')) {
    const u = await currentUser(req);
    if (!u) return json(res, 401, { error: 'unauthorized' });
    const id = path.basename(decodeURIComponent(pathOnly));
    const a = await authAttachment(id, u);
    if (!a) return json(res, 404, { error: 'not found' });
    media.ensureWebRendition(id, a.mime);        // idempotent — also backfills pre-existing uploads
    const ready = media.hasWebRendition(id);
    const fp = ready ? media.webPath(id) : path.join(UPLOADS_DIR, id);
    if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
    return sendRanged(req, res, fp, {
      'Content-Type': ready ? 'video/mp4' : (a.mime || 'application/octet-stream'),
      'Content-Disposition': 'inline',
      // Don't let a browser cache the heavy original past the point the rendition lands.
      'Cache-Control': ready ? 'private, max-age=86400' : 'private, max-age=60',
    });
  }
  if (pathOnly.startsWith('/files/')) {
    const u = await currentUser(req);
    if (!u) return json(res, 401, { error: 'unauthorized' });
    const id = path.basename(decodeURIComponent(pathOnly));
    const a = await authAttachment(id, u);
    if (!a) return json(res, 404, { error: 'not found' });
    const fp = path.join(UPLOADS_DIR, id);
    if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' });
    const inline = /^(image|video|audio)\//.test(a.mime || ''); // media opens/plays inline; other files download
    const safeName = String(a.name || 'file').replace(/[\r\n"]/g, '');
    return sendRanged(req, res, fp, {
      'Content-Type': a.mime || 'application/octet-stream',
      'Content-Disposition': (inline ? 'inline' : 'attachment') + '; filename="' + safeName + '"',
      'Cache-Control': 'private, max-age=86400',
    });
  }
  return serveStatic(req, res);
}

module.exports = { handleGet, serveStatic };
