/* ============================================================================
 * server.js — YT Player PWA backend
 *
 * Runtime:  Bun (https://bun.sh)
 * Framework: Hono v4
 * DB:        libsql (concurrent SQLite fork, embedded file mode)
 *
 * Endpoints:
 *   GET  /api/search?q=<query>         yt-dlp search → slim card array
 *   GET  /api/channel?c=<channel>      yt-dlp channel uploads → slim card array
 *   GET  /api/streams?v=<videoId>      yt-dlp stream info → {meta, audioUrl, qualities}
 *   GET  /api/download/:videoId        proxy best progressive stream → binary
 *   GET  /api/version                  { version }
 *   POST /api/user/sync                upsert user playlists + last-seen version
 *   GET  /api/user/data?fp=<fp>        retrieve stored playlists + history
 *   GET  /*                            serve frontend/public static files
 *
 * JSON shapes mirror the Tauri (Rust) bridge exactly so the existing app.js
 * UI code works without modification in WEB mode.
 * ========================================================================== */

import { Hono }         from 'hono';
import { serveStatic }  from 'hono/bun';
import { logger }       from 'hono/logger';
import { spawnSync }    from 'node:child_process';
import { createServer } from 'node:http';
import { initDb, upsertUser, recordVideoAccess, getUserData } from './db.js';

const PORT        = parseInt(process.env.PORT || '3000', 10);
const APP_VERSION = process.env.APP_VERSION || '1.0.0';
const YTDLP       = process.env.YTDLP_PATH  || 'yt-dlp';

const SEARCH_LIMIT  = 25;
const CHANNEL_LIMIT = 60;

// ============================================================================
// yt-dlp helpers
// ============================================================================

// Run yt-dlp synchronously and return stdout as a string.
// Throws on non-zero exit.
function runYtdlp(args) {
  const result = spawnSync(YTDLP, args, {
    encoding: 'utf8',
    maxBuffer: 32 * 1024 * 1024, // 32 MB — large channel dumps can be big
  });
  if (result.error) throw new Error('yt-dlp not found: ' + result.error.message);
  if (result.status !== 0) {
    const err = (result.stderr || '').trim();
    throw new Error(err || 'yt-dlp exited with code ' + result.status);
  }
  return result.stdout || '';
}

// Helpers to pick the right field from a yt-dlp JSON record
function pick(obj, ...keys) {
  for (const k of keys) {
    const v = obj[k];
    if (v && typeof v === 'string' && v.trim()) return v.trim();
  }
  return '';
}

function pickChannel(obj)    { return pick(obj, 'channel', 'uploader'); }
function pickChannelUrl(obj) { return pick(obj, 'channel_url', 'uploader_url'); }
function pickChannelId(obj)  { return pick(obj, 'channel_id', 'uploader_id'); }

// Normalise a flat-playlist yt-dlp record into the slim UI card shape
function slimEntry(j) {
  const id = pick(j, 'id');
  if (!id) return null;
  return {
    id,
    title:      pick(j, 'title') || '(untitled)',
    channel:    pickChannel(j),
    channelId:  pickChannelId(j),
    channelUrl: pickChannelUrl(j),
    duration:   typeof j.duration === 'number' ? j.duration : 0,
    thumbnail:  `https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
  };
}

// Parse multi-line JSON output from yt-dlp --dump-json --flat-playlist
function parseCards(output) {
  const results = [];
  for (const line of output.split('\n')) {
    const t = line.trim();
    if (!t) continue;
    try {
      const j = JSON.parse(t);
      const card = slimEntry(j);
      if (card) results.push(card);
    } catch { /* skip malformed lines */ }
  }
  return results;
}

// Resolve a channel identifier to a /videos URL yt-dlp can fetch
function channelToUrl(c) {
  let base = c.trim();
  if (!base.startsWith('http')) {
    base = base.startsWith('@') ? `https://www.youtube.com/${base}`
         : base.startsWith('UC') ? `https://www.youtube.com/channel/${base}`
         : `https://www.youtube.com/@${base}`;
  }
  base = base.replace(/\/$/, '');
  return base.endsWith('/videos') ? base : base + '/videos';
}

// ============================================================================
// App setup
// ============================================================================

const app = new Hono();
app.use('*', logger());

// ============================================================================
// API routes
// ============================================================================

// GET /api/version
app.get('/api/version', (c) => c.json({ version: APP_VERSION }));

// GET /api/search?q=<query>
app.get('/api/search', async (c) => {
  const q = (c.req.query('q') || '').trim();
  if (!q) return c.json({ ok: false, error: 'empty query' }, 400);

  try {
    const out = runYtdlp([
      `ytsearch${SEARCH_LIMIT}:${q}`,
      '--dump-json', '--flat-playlist',
      '--no-warnings', '--ignore-errors',
    ]);
    return c.json({ ok: true, results: parseCards(out) });
  } catch (err) {
    return c.json({ ok: false, error: err.message }, 500);
  }
});

// GET /api/channel?c=<channel>
app.get('/api/channel', async (c) => {
  const chan = (c.req.query('c') || '').trim();
  if (!chan) return c.json({ ok: false, error: 'missing channel' }, 400);

  try {
    const url = channelToUrl(chan);
    const out = runYtdlp([
      url,
      '--dump-json', '--flat-playlist',
      '--no-warnings', '--ignore-errors',
      '--playlist-end', String(CHANNEL_LIMIT),
    ]);
    const results = parseCards(out);
    // Extract channel name + URL from the first record
    const first = results[0];
    let channelName = '', channelUrl = '';
    for (const line of out.split('\n')) {
      const t = line.trim();
      if (!t) continue;
      try {
        const j = JSON.parse(t);
        channelName = channelName || pickChannel(j);
        channelUrl  = channelUrl  || pickChannelUrl(j);
        if (channelName && channelUrl) break;
      } catch { /* skip */ }
    }
    return c.json({ ok: true, channel: channelName || (first?.channel || ''), channelUrl, results });
  } catch (err) {
    return c.json({ ok: false, error: err.message }, 500);
  }
});

// GET /api/streams?v=<videoId>
app.get('/api/streams', async (c) => {
  const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
  if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);

  try {
    const url = `https://www.youtube.com/watch?v=${videoId}`;
    const out = runYtdlp(['-J', '--no-warnings', url]);
    const info = JSON.parse(out);
    const formats = Array.isArray(info.formats) ? info.formats : [];

    // Best audio-only stream (prefer mp4a/m4a by bitrate)
    let bestAudioUrl   = null;
    let bestAudioScore = -1;
    for (const f of formats) {
      if (!f.url) continue;
      const hasVideo = f.vcodec && f.vcodec !== 'none';
      const hasAudio = f.acodec && f.acodec !== 'none';
      if (hasVideo || !hasAudio) continue;
      let score = f.abr || 0;
      if (f.acodec && f.acodec.includes('mp4a')) score += 1000;
      if (score > bestAudioScore) { bestAudioScore = score; bestAudioUrl = f.url; }
    }

    // Quality list — progressive (single-file, hasAudio) first, then adaptive
    const qualities = [];
    const seen = new Set();
    for (const wantProg of [true, false]) {
      for (const f of formats) {
        if (!f.url) continue;
        const hasVideo = f.vcodec && f.vcodec !== 'none';
        const hasAudio = f.acodec && f.acodec !== 'none';
        if (!hasVideo) continue;
        if ((hasAudio) !== wantProg) continue;
        const h = f.height || 0;
        if (h <= 0 || seen.has(h)) continue;
        seen.add(h);
        qualities.push({ label: h + 'p', height: h, hasAudio: !!hasAudio, url: f.url, ext: f.ext || '' });
      }
    }
    qualities.sort((a, b) => b.height - a.height);

    return c.json({
      ok: true,
      data: {
        meta: {
          id:         videoId,
          title:      pick(info, 'title') || '(untitled)',
          channel:    pickChannel(info),
          channelId:  pickChannelId(info),
          channelUrl: pickChannelUrl(info),
          duration:   typeof info.duration === 'number' ? info.duration : 0,
          thumbnail:  `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
        },
        audioUrl:  bestAudioUrl,
        qualities,
      },
    });
  } catch (err) {
    return c.json({ ok: false, error: err.message }, 500);
  }
});

// GET /api/download/:videoId
// Resolves the best progressive (audio+video single-file) stream URL via
// yt-dlp and proxies the binary to the browser so OPFS can store it.
// The browser never contacts YouTube CDN directly (CORS would block it).
app.get('/api/download/:videoId', async (c) => {
  const videoId = (c.req.param('videoId') || '').replace(/[/\\:?<>|*"]/g, '').trim();
  if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);

  try {
    const url = `https://www.youtube.com/watch?v=${videoId}`;
    // --get-url with bestvideo+bestaudio/best format is not what we want
    // here — we need a SINGLE FILE so no ffmpeg muxing is required in the
    // browser. Use -f "bestvideo[ext=mp4][acodec!=none]/best[ext=mp4]/best"
    const out = runYtdlp([
      url,
      '--no-warnings',
      '-f', 'bestvideo[ext=mp4][acodec!=none]/bestvideo[acodec!=none]/best[ext=mp4]/best',
      '--get-url',
    ]);
    const streamUrl = out.trim();
    if (!streamUrl) throw new Error('No stream URL returned');

    // Fetch from YouTube and pipe to the client
    const upstream = await fetch(streamUrl, {
      headers: {
        // Mimic a browser to avoid 403s from YouTube CDN
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
        'Referer':    'https://www.youtube.com/',
      },
    });
    if (!upstream.ok) throw new Error(`Upstream error ${upstream.status}`);

    const contentType   = upstream.headers.get('content-type') || 'video/mp4';
    const contentLength = upstream.headers.get('content-length');

    const headers = new Headers({
      'Content-Type':        contentType,
      'Content-Disposition': `attachment; filename="${videoId}.mp4"`,
      'Cache-Control':       'no-store',
      'Access-Control-Allow-Origin': '*',
    });
    if (contentLength) headers.set('Content-Length', contentLength);

    // Log the access if a fingerprint was supplied (fire-and-forget)
    const fp = c.req.query('fp');
    if (fp) {
      recordVideoAccess(fp, { id: videoId }).catch(() => {});
    }

    return new Response(upstream.body, { status: 200, headers });
  } catch (err) {
    return c.json({ ok: false, error: err.message }, 500);
  }
});

// POST /api/user/sync
// Body: { fingerprint, playlists?, recentVideo?, appVersion? }
app.post('/api/user/sync', async (c) => {
  let body;
  try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }

  const fp = (body.fingerprint || '').trim();
  if (!fp) return c.json({ ok: false, error: 'missing fingerprint' }, 400);

  try {
    await upsertUser({ fingerprint: fp, appVersion: body.appVersion, playlists: body.playlists });
    if (body.recentVideo && body.recentVideo.id) {
      await recordVideoAccess(fp, body.recentVideo);
    }
    return c.json({ ok: true });
  } catch (err) {
    return c.json({ ok: false, error: err.message }, 500);
  }
});

// GET /api/user/data?fp=<fingerprint>
app.get('/api/user/data', async (c) => {
  const fp = (c.req.query('fp') || '').trim();
  if (!fp) return c.json({ ok: false, error: 'missing fingerprint' }, 400);
  try {
    const userData = await getUserData(fp);
    return c.json({ ok: true, ...userData, version: APP_VERSION });
  } catch (err) {
    return c.json({ ok: false, error: err.message }, 500);
  }
});

// ============================================================================
// Static files — serve the frontend/public directory
// Must come AFTER all /api routes so API takes priority
// ============================================================================
app.use('/*', serveStatic({ root: './public' }));
// SPA fallback — return index.html for any unmatched path
app.get('/*', serveStatic({ path: './public/index.html' }));

// ============================================================================
// Boot
// ============================================================================
async function main() {
  await initDb();
  console.log(`[ytplayer] DB ready`);
  console.log(`[ytplayer] Starting on port ${PORT}`);

  // Bun.serve is the native Bun HTTP server
  Bun.serve({
    port: PORT,
    fetch: app.fetch,
  });

  console.log(`[ytplayer] Listening → http://localhost:${PORT}`);
}

main().catch((err) => {
  console.error('[ytplayer] fatal:', err);
  process.exit(1);
});
