import Fastify from 'fastify';
import cors from '@fastify/cors';
import { Pool } from 'pg';
import { createClient as createRedisClient } from 'redis';
import { DbService } from './services/db.service.js';
import { JobService } from './services/job.service.js';
import { SearchService } from './services/search.service.js';
import libraryRoutes from './routes/library.routes.js';
import searchRoutes from './routes/search.routes.js';
import adminRoutes from './routes/admin.routes.js';
import historyRoutes from './routes/history.routes.js';
import streamRoutes from './routes/stream.routes.js';
import quarantineRoutes from './routes/quarantine.routes.js';
import settingsRoutes from './routes/settings.routes.js';
import graphRoutes from './routes/graph.routes.js';
import { SessionDirector } from './services/session-director.service.js';
import vibeSessionsRoutes from './routes/vibe-sessions.routes.js';
import discoveryRoutes from './routes/discovery.routes.js';
import imagesRoutes from './routes/images.routes.js';
import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js';
import { DiscoveryService } from './services/discovery.service.js';
import { PlaybackSyncService } from './services/playback-sync.service.js';
import playbackRoutes from './routes/playback.routes.js';

// Single-listener deployment: the same placeholder the vibe-session routes use
// when no x-user-id header is supplied.
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';

export interface AppConfig {
  port: number;
  searchHost: string;
  searchPort: number;
  searchApiKey: string;
}

export async function buildApp(config: AppConfig) {
  const fastify = Fastify({ logger: true });

  await fastify.register(cors, {
    origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:5173', 'http://localhost:5174'],
  });

  // A Pool (not a single Client) so concurrent requests get independent
  // connections. Critical for correctness: recordPlay/recordSkip run
  // BEGIN...COMMIT transactions, and other requests' queries must not be
  // able to land inside another request's open transaction on a shared
  // connection (see db.service.ts withTransaction()).
  const pgPool = new Pool({
    connectionString: process.env.DATABASE_URL,
  });

  const redisClient = createRedisClient({
    url: process.env.REDIS_URL,
  });
  await redisClient.connect();

  const jobService = new JobService({ redisUrl: process.env.REDIS_URL! });
  const searchService = new SearchService({
    host: config.searchHost,
    port: config.searchPort,
    protocol: 'http',
    apiKey: config.searchApiKey,
  });
  const dbService = new DbService(pgPool, searchService);

  // Apply the idempotent schema on boot so tables added after the initial DB
  // volume was created (e.g. play_history, feedback) exist. The init-time
  // docker-entrypoint mount only runs on first init, so older volumes miss them.
  await dbService.ensureSchema();
  await dbService.runMigrations();

  // Keep the claim_fusion materialised view fresh. The trigger on
  // `claims` fires NOTIFY on every change; rather than maintain a
  // LISTEN consumer (separate long-lived connection), we refresh on a
  // short interval. 10s staleness is well below any user-facing
  // latency for a homelab music player.
  const FUSION_REFRESH_MS = 10_000;
  const fusionTimer = setInterval(() => {
    dbService.refreshClaimFusion().catch(() => {});
  }, FUSION_REFRESH_MS);

  // Daily belief decay (spec §B.4). Runs hourly; the SQL only touches
  // beliefs whose last_decayed_at is >1h old, so frequent runs are safe.
  const DECAY_INTERVAL_MS = 60 * 60 * 1000;
  const decayTimer = setInterval(() => {
    dbService.decayBeliefs().catch((e) => console.error('[DB] belief decay failed:', e));
  }, DECAY_INTERVAL_MS);

  // Nightly 'forgotten' profile derivation (spec §B.2).
  const FORGOTTEN_INTERVAL_MS = 24 * 60 * 60 * 1000;
  const forgottenTimer = setInterval(() => {
    dbService.deriveForgottenProfile().catch((e) =>
      console.error('[DB] forgotten derivation failed:', e)
    );
  }, FORGOTTEN_INTERVAL_MS);

  // Run both once at boot so the first session benefits.
  dbService.decayBeliefs().catch(() => {});
  dbService.deriveForgottenProfile().catch(() => {});

  // Close the discovery loop without an operator in it. The worker's cron jobs
  // generate candidates; this timer is what decides which of them earn a
  // probation slot and enqueues the download. Evaluation is cheap SQL against a
  // capped batch, and every acquisition gate (relevance, novelty tolerance,
  // backlog, per-artist diversity) lives inside evalCandidates.
  const discoveryService = new DiscoveryService(dbService);
  const DISCOVERY_EVAL_INTERVAL_MS = 30 * 60 * 1000;
  const runDiscoveryEval = async () => {
    const results = await discoveryService.evalCandidates(DEFAULT_USER_ID);
    let enqueued = 0;
    for (const result of results) {
      if (!result.shouldAcquire) continue;
      try {
        await jobService.enqueueDiscoveryAcquisition(result.candidateId);
        enqueued++;
      } catch (err) {
        const reason = err instanceof Error ? err.message : 'failed to enqueue acquisition';
        await discoveryService.markEnqueueFailed(result.candidateId, reason);
      }
    }
    if (results.length > 0) {
      console.log(`[Discovery] Evaluated ${results.length} candidates, enqueued ${enqueued}`);
    }
  };
  const discoveryEvalTimer = setInterval(() => {
    runDiscoveryEval().catch((e) => console.error('[Discovery] eval failed:', e));
  }, DISCOVERY_EVAL_INTERVAL_MS);

  // Cross-device playback. A device that dies without closing its stream — a
  // phone going to sleep, a laptop lid — leaves the session owned by something
  // that will never play again, so a sweep frees ownership once its heartbeat
  // has been silent past the staleness window.
  const playbackSync = new PlaybackSyncService(pgPool);
  const DEVICE_REAP_INTERVAL_MS = 60 * 1000;
  const deviceReapTimer = setInterval(() => {
    playbackSync.reapStaleDevices().catch((e) => console.error('[Playback] device reap failed:', e));
  }, DEVICE_REAP_INTERVAL_MS);

  // Ensure the Typesense 'tracks' collection schema exists on boot so that
  // the first search request doesn't hit a 404.
  await searchService.ensureCollection();

  // Auth: optional MUZICK_API_KEY / MUZICK_ADMIN_KEY
  const apiKey = process.env.MUZICK_API_KEY;
  const adminKey = process.env.MUZICK_ADMIN_KEY;
  fastify.addHook('onRequest', async (request, reply) => {
    if (!apiKey && !adminKey) return;
    const path = request.url;
    if (path === '/api/health') return;
    const auth = request.headers.authorization || '';
    const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
    if (!token) return reply.code(401).send({ error: 'Unauthorized' });
    // admin routes require admin key specifically
    if (path.startsWith('/api/admin/') && adminKey) {
      if (token !== adminKey) return reply.code(403).send({ error: 'Admin access denied' });
      return;
    }
    // regular routes accept either key
    if (token === apiKey || (adminKey && token === adminKey)) return;
    reply.code(401).send({ error: 'Unauthorized' });
  });

  fastify.get('/api/health', async (request, reply) => {
    const status = {
      postgres: 'unknown',
      redis: 'unknown',
    };

    try {
      await pgPool.query('SELECT 1');
      status.postgres = 'ok';
    } catch (err) {
      status.postgres = 'error';
      fastify.log.error(err);
    }

    try {
      const redisRes = await redisClient.ping();
      if (redisRes === 'PONG') {
        status.redis = 'ok';
      }
    } catch (err) {
      status.redis = 'error';
      fastify.log.error(err);
    }

    const isHealthy = status.postgres === 'ok' && status.redis === 'ok';

    if (isHealthy) {
      return reply.code(200).send(status);
    } else {
      return reply.code(503).send(status);
    }
  });

  fastify.register(imagesRoutes, { prefix: '/api' });
  fastify.register(libraryRoutes, { prefix: '/api', dbService });
  fastify.register(searchRoutes, { prefix: '/api', dbService });
  fastify.register(adminRoutes, { prefix: '/api/admin', jobService, dbService });
  fastify.register(historyRoutes, { prefix: '/api', dbService });
  fastify.register(streamRoutes, { prefix: '/api', dbService });
  fastify.register(quarantineRoutes, { prefix: '/api', dbService });
  fastify.register(settingsRoutes, { prefix: '/api', dbService });
  fastify.register(graphRoutes, { prefix: '/api', dbService });

  const sessionDirector = new SessionDirector(dbService);
  const vibeSessionCoordinator = new VibeSessionCoordinator(dbService, sessionDirector);

  fastify.register(vibeSessionsRoutes, {
    prefix: '/api',
    coordinator: vibeSessionCoordinator,
  });
  fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService });
  fastify.register(playbackRoutes, { prefix: '/api', playbackSync });
  // ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
  // nothing in the deployed app or its tests called it, and deployment never
  // sets NODE_ENV so an env gate would've stayed live in prod anyway.

  // Register hooks to close connections on shutdown
  fastify.addHook('onClose', async () => {
    try {
      clearInterval(fusionTimer);
      clearInterval(decayTimer);
      clearInterval(forgottenTimer);
      clearInterval(discoveryEvalTimer);
      clearInterval(deviceReapTimer);
    } catch (err) {
      fastify.log.error(err);
    }
    try {
      await pgPool.end();
    } catch (err) {
      fastify.log.error(err);
    }
    try {
      await redisClient.quit();
    } catch (err) {
      fastify.log.error(err);
    }
    try {
      await jobService.close();
    } catch (err) {
      fastify.log.error(err);
    }
  });

  return { fastify, pgPool, redisClient };
}
