import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { streamAudioFile } from './stream-audio';
import {
  reindexNow,
  ensureCacheSetup,
  getIndexStatus,
  getManifest,
  albumVersion,
  metaFilePath,
  coverFilePath,
  discographyFilePath,
  posterFilePath,
  lyricsFilePath,
  onIndexProgress,
  buildReport,
} from './indexer';

const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');

// The music sidecar (officer-music). Same philosophy as the other officer-* sidecars: a singleton
// process that registers with the API server. It OWNS an audio-streaming HTTP server (all path
// resolution + streaming + ffprobe duration happens here); the platform API is just a thin proxy that
// authenticates and forwards to us. The server listens on a random loopback port, reported to the API
// on connect so it can route `/api/music/*` here.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the full `/api/music/*` surface (this fetch handler is the source of truth; the
// platform side is an opaque catch-all proxy). All routes are reached as `/api/music/<name>`, authed
// upstream by userMiddleware (Bearer header or `?token=` for media). Data shapes are the exported
// `IndexStatus` / `IndexReport` / `IndexMeta` types in indexer.ts.
//
//   GET  /stream?path=<home-relative>      audio with Range→206 (Content-Range/Length/Accept-Ranges)
//                                          + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
//   GET  /manifest                         pure read of the last completed index (NO build triggered) —
//                                          { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } }
//   GET  /meta?path=<rel>                  album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
//   GET  /cover?path=<rel>                 compressed cover.jpg.       ETag: <v>; If-None-Match → 304.
//   GET  /poster?path=<rel>&file=<video>   compressed video poster (frame grab). ETag: <v>; 304. 404 if none.
//   GET  /lyrics?path=<rel>&file=<track>   track lyrics text (X-Lyrics-Format: lrc|txt). ETag: <v>; 304. 404 if none.
//   GET  /discography?path=<artist rel>    artist discography.json (only where manifest entry has
//                                          disco:true) = { artist, albums: { "<[year] album folder>":
//                                          "<Type>" } }, Type ∈ Studio/Live/Compilation/Single/EP/…
//                                          ETag: <v>; If-None-Match → 304. Source: each artist folder's
//                                          _discography.md (normalized; the md itself is never modified).
//   POST /reindex                          run the build to COMPLETION, then return the final IndexStatus.
//   GET  /reindex/status                   IndexStatus snapshot.
//   GET  /reindex/stream                   SSE. Triggers a build if idle (`?trigger=0` = watch-only).
//                                          `event: progress` (IndexStatus) throttled ~200ms, then one
//                                          `event: done` (IndexReport) and the stream closes.
//   GET  /health                           "ok".
//
// `<rel>` = album folder path relative to the Music root (e.g. "Albums/AC-DC/[1980] Back in Black").
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
// ─────────────────────────────────────────────────────────────────────────────────────────────────

const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;

// ── Audio-streaming HTTP server ──

/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
  const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
  const port = probe.port;
  probe.stop(true);
  if (port == null) throw new Error('failed to acquire a free port');
  return port;
}

const port = getFreePort();

// Ensure the cache is a symlink-to-slot before serving/building, so full reindexes can swap atomically.
await ensureCacheSetup();

const server = Bun.serve({
  port,
  hostname: '127.0.0.1',
  // Bun caps the server-level idleTimeout at 255s. Keep it there as the baseline; the build/stream
  // endpoints (which can idle for a whole from-scratch rebuild) extend it per-request via server.timeout.
  idleTimeout: 255,
  async fetch(req, server) {
    const url = new URL(req.url);
    // A from-scratch reindex can take many minutes with no bytes flowing on the triggering request.
    // Give the build endpoints a 30-min idle timeout so they aren't dropped (/manifest is a pure read now).
    if (url.pathname === '/reindex' || url.pathname === '/reindex/stream') {
      server.timeout(req, 1800);
    }
    const json = (data: unknown, init?: ResponseInit) =>
      new Response(JSON.stringify(data), { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers } });

    if (url.pathname === '/health') return new Response('ok');

    // ── Streaming ──
    if (url.pathname === '/stream') {
      const path = url.searchParams.get('path');
      if (!path) return new Response('path is required', { status: 400 });
      return streamAudioFile(path, req.headers.get('range'));
    }

    // ── Index build ──
    if (url.pathname === '/reindex') {
      if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
      // Run the resync to completion, THEN respond — so the caller's manifest read right after is fresh.
      // Incremental builds are near-instant (unchanged albums skip by version stamp). reindexNow joins
      // an in-flight build rather than starting a second.
      const result = await reindexNow();
      return json(result);
    }
    if (url.pathname === '/reindex/status') return json(getIndexStatus());

    // SSE progress stream (for the app + the CLI). Triggers a build if idle (unless ?trigger=0), then
    // streams `progress` events until the build finishes, ending with a `done` event carrying the report.
    if (url.pathname === '/reindex/stream') {
      const trigger = url.searchParams.get('trigger') !== '0';
      if (trigger) void reindexNow();

      const encoder = new TextEncoder();
      const stream = new ReadableStream({
        start(controller) {
          let closed = false;
          let unsub = () => {};
          const send = (event: string, data: unknown) => {
            if (closed) return;
            try {
              controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
            } catch {
              /* stream closed */
            }
          };
          const finish = (s: ReturnType<typeof getIndexStatus>) => {
            send('done', buildReport(s));
            unsub();
            closed = true;
            try {
              controller.close();
            } catch {
              /* already closed */
            }
          };

          send('progress', getIndexStatus());
          const cur = getIndexStatus();
          if (!cur.running) {
            finish(cur); // nothing running → emit the last report and close
            return;
          }
          unsub = onIndexProgress((s) => {
            send('progress', s);
            if (!s.running && s.finishedAt) finish(s);
          });
        },
      });

      return new Response(stream, {
        headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
      });
    }

    // ── Sync surface ──
    if (url.pathname === '/manifest') {
      // Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
      // long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
      return json(await getManifest());
    }

    // Video poster (a compressed frame grab). Keyed by the video's folder rel + its filename.
    if (url.pathname === '/poster') {
      const rel = url.searchParams.get('path');
      const file = url.searchParams.get('file');
      if (rel === null || !file) return new Response('path and file are required', { status: 400 });
      const posterPath = posterFilePath(rel, file);
      if (!posterPath) return new Response('Invalid path', { status: 400 });
      if (!(await Bun.file(posterPath).exists())) return new Response('Not found', { status: 404 });
      const v = await albumVersion(rel);
      if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
      return new Response(Bun.file(posterPath), { headers: { 'Content-Type': 'image/jpeg', ...(v ? { ETag: v } : {}) } });
    }

    // Track lyrics (external .lrc/.txt sidecar or embedded, cached during indexing). Try synced first.
    if (url.pathname === '/lyrics') {
      const rel = url.searchParams.get('path');
      const file = url.searchParams.get('file');
      if (rel === null || !file) return new Response('path and file are required', { status: 400 });
      for (const fmt of ['lrc', 'txt'] as const) {
        const p = lyricsFilePath(rel, file, fmt);
        if (p && (await Bun.file(p).exists())) {
          const v = await albumVersion(rel);
          if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
          return new Response(Bun.file(p), {
            headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Lyrics-Format': fmt, ...(v ? { ETag: v } : {}) },
          });
        }
      }
      return new Response('Not found', { status: 404 });
    }

    if (url.pathname === '/meta' || url.pathname === '/cover' || url.pathname === '/discography') {
      const rel = url.searchParams.get('path');
      if (rel === null) return new Response('path is required', { status: 400 });
      const spec = {
        '/meta': { file: metaFilePath(rel), type: 'application/json' },
        '/cover': { file: coverFilePath(rel), type: 'image/jpeg' },
        '/discography': { file: discographyFilePath(rel), type: 'application/json' },
      }[url.pathname]!;
      if (!spec.file) return new Response('Invalid path', { status: 400 });
      if (!(await Bun.file(spec.file).exists())) return new Response('Not found', { status: 404 });

      const v = await albumVersion(rel);
      if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
      return new Response(Bun.file(spec.file), {
        headers: { 'Content-Type': spec.type, ...(v ? { ETag: v } : {}) },
      });
    }

    return new Response('Not found', { status: 404 });
  },
});

console.log(`[music] audio server listening on http://127.0.0.1:${port}`);

// Write the port to a well-known file so local tooling (scripts/reindex-music.ts) can find the server.
try {
  mkdirSync(join(DATA_PATH, 'music'), { recursive: true });
  writeFileSync(join(DATA_PATH, 'music', '.server'), String(port));
} catch {
  /* best-effort */
}

// ── Command handlers ──

type ReplyFn = (msg: SidecarEvent) => void;

function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
  switch (cmd.type) {
    case 'ping':
      reply({ type: 'pong', id: cmd.id });
      break;

    default:
      reply({
        type: 'error',
        id: (cmd as SidecarCommand).id,
        error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
      });
  }
}

// ── Connect to API server ──

const connection = createSidecarConnector({
  apiUrl: `${API_URL}/api/sidecar/register`,
  name: 'music',
  capabilities: ['music'],
  onCommand(cmd, reply) {
    handleCommand(cmd as SidecarCommand, reply as ReplyFn);
  },
  onConnected() {
    // Tell the API where our audio server is listening, so it can proxy /api/music/* here.
    connection.send({ type: 'music:server', port });
    console.log(`[music] reported audio server port ${port} to API`);
  },
});

// ── Graceful shutdown ──

function shutdown(signal: string) {
  console.log(`[music] ${signal} received, shutting down...`);
  try {
    server.stop(true);
  } catch {
    /* already stopped */
  }
  connection.destroy();
  process.exit(0);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
