import { Worker, Job } from 'bullmq';
import { connection, QUEUE_NAME, queue } from './queue.js';
import { MetadataRefreshJob, AudioAnalysisJob, AudioAnalysisSweepJob, LibraryScanJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, ReprocessArtistsJob, AcquisitionJob, DiscoverySourceJob } from './types.js';
import { Pool } from 'pg';
import { ScannerService } from './scanner.service.js';
import { IntegrityService } from './integrity.service.js';
import { EnrichmentService } from './enrichment.service.js';
import { AudioFeaturesService } from './audio-features.service.js';
import { CleanupSweepService } from './cleanup.service.js';
import { reprocessArtists } from './reprocess-artists.service.js';
import { AcquisitionService } from './acquisition.service.js';
import { DiscoverySourcesService } from './discovery-sources.service.js';
import { AUDIO_ANALYSIS_JOB_OPTIONS, AUDIO_ANALYSIS_VERSION, audioAnalysisJobId } from './audio-analysis.js';

// Cron for the periodic integrity sweep (default: daily at 03:00). Configurable
// via INTEGRITY_SWEEP_CRON. MUSIC_DIR (consumed by IntegrityService) controls
// the rescan root.
const INTEGRITY_SWEEP_CRON = process.env.INTEGRITY_SWEEP_CRON || '0 3 * * *';
// Cron for the dislike cleanup sweep (default: every 6 hours). Configurable via CLEANUP_SWEEP_CRON.
const CLEANUP_SWEEP_CRON = process.env.CLEANUP_SWEEP_CRON || '0 */6 * * *';
// Cron for the stale vibe-session reaper (default: hourly). Configurable via
// VIBE_REAP_CRON. Spec §4 / Invariant B: ACTIVE batches with no interaction for
// 24h must transition to RESOLVED so returning users start fresh sessions.
const VIBE_REAP_CRON = process.env.VIBE_REAP_CRON || '0 * * * *';
const PROBATION_SWEEP_CRON = process.env.PROBATION_SWEEP_CRON || '15 * * * *';
// Candidate generation runs daily, not hourly: both sources are seeded from
// play counts, which barely move in an hour, and every extra run is external
// API traffic that returns the same rows. New releases are checked at a
// different hour from similarity so the two never contend for the proxy.
const NEW_RELEASE_CRON = process.env.NEW_RELEASE_CRON || '30 5 * * *';
const RECOMMENDATION_CRON = process.env.RECOMMENDATION_CRON || '30 6 * * *';
// A small daily backfill is intentionally bounded. It refreshes stale v1
// measurements over time without turning a worker restart into a library-wide
// ffmpeg/Essentia batch.
const AUDIO_ANALYSIS_SWEEP_CRON = process.env.AUDIO_ANALYSIS_SWEEP_CRON || '20 4 * * *';
const configuredAudioBatchSize = Number.parseInt(process.env.AUDIO_ANALYSIS_BATCH_SIZE || '25', 10);
const AUDIO_ANALYSIS_BATCH_SIZE = Number.isFinite(configuredAudioBatchSize)
  ? Math.min(100, Math.max(1, configuredAudioBatchSize))
  : 25;
// Worker concurrency - how many jobs to process in parallel
const WORKER_CONCURRENCY = parseInt(process.env.WORKER_CONCURRENCY || '10', 10);
const AUDIO_ANALYSIS_CONCURRENCY = Math.max(1, Math.min(2, parseInt(process.env.AUDIO_ANALYSIS_CONCURRENCY || '1', 10) || 1));
let activeAudioAnalyses = 0;
const audioWaiters: Array<() => void> = [];
async function withAudioSlot<T>(fn: () => Promise<T>): Promise<T> {
  if (activeAudioAnalyses >= AUDIO_ANALYSIS_CONCURRENCY) {
    await new Promise<void>((resolve) => audioWaiters.push(resolve));
  }
  activeAudioAnalyses++;
  try { return await fn(); }
  finally {
    activeAudioAnalyses--;
    audioWaiters.shift()?.();
  }
}

// A Pool, not a single Client. The worker processes jobs with
// `concurrency: 10` on one event loop, so a shared Client would multiplex every
// job's queries onto one connection: a `BEGIN` issued by one job (see
// cleanup.service.ts) would enrol other jobs' unrelated queries in that
// transaction and discard them on `ROLLBACK`. Pool gives each transaction its
// own connection. Same reasoning as backend/src/app.ts.
//
// Sized at least as large as the job concurrency so concurrent jobs never
// serialise waiting for a connection.
const pgPool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: Math.max(WORKER_CONCURRENCY + 2, 10),
});

pgPool.on('error', (err) => {
  // Idle-client errors would otherwise be an unhandled 'error' event and crash
  // the worker; the pool discards the client itself.
  console.error('[DB] Idle pool client error:', err);
});

async function initWorker() {
  // Fail fast if the database is unreachable at boot (Pool is lazy otherwise).
  await pgPool.query('SELECT 1');
  console.log('Worker connected to PostgreSQL');

  const scannerService = new ScannerService(pgPool, queue);
  const acquisitionService = new AcquisitionService(pgPool, scannerService);
  const discoverySources = new DiscoverySourcesService(pgPool);
  const enrichmentService = new EnrichmentService(pgPool);
  const audioFeaturesService = new AudioFeaturesService(pgPool);
  await audioFeaturesService.ensureSchema();

  // Self-provision the enrichment schema additions (artist_similar + reused
  // columns) at startup, alongside the integrity table.
  await enrichmentService.ensureSchema();
  await new IntegrityService(pgPool, queue).ensureSchema();

  const worker = new Worker(QUEUE_NAME, async (job: Job<any>) => {
    // console.log(`Processing job: ${job.name} (ID: ${job.id})`);
    console.log(`Processing job: ${job.name} (ID: ${job.id})`);
    
    switch (job.name) {
      case 'scan_library': {
        const payload = job.data as LibraryScanJob;
        console.log(`[Scanner] Starting library scan in: ${payload.directory}`);
        await scannerService.scanDirectory(payload.directory);
        // Re-sync Typesense after every scan so search reflects new/changed tracks.
        await queue.add('reindex_tracks', {}, { removeOnComplete: { age: 86400, count: 100 }, removeOnFail: { age: 86400, count: 100 } });
        console.log(`[Scanner] Enqueued Typesense reindex after scan`);
        break;
      }
      case 'metadata_refresh': {
        const payload = job.data as MetadataRefreshJob;
        console.log(`[Metadata] Refreshing track: ${payload.trackId} (Mode: ${payload.refreshType})`);

        // Real best-effort enrichment via the external-integration clients.
        // Each provider is isolated inside the service so one failing source
        // never aborts the others or fails the job.
        const outcome = await enrichmentService.enrichTrack(payload.trackId);
        console.log(`[Metadata] ${payload.trackId}: ${outcome.outcome}`);
        return outcome;
      }
      case 'artist_similarity': {
        const payload = job.data as ArtistSimilarityJob;
        console.log(`[Similarity] Refreshing similar artists for: ${payload.artistId}`);
        await enrichmentService.refreshArtistSimilarity(payload.artistId);
        break;
      }
      case 'artist_image': {
        const payload = job.data as ArtistImageJob;
        console.log(`[ArtistImage] Refreshing image for artist: ${payload.artistId}`);
        return await enrichmentService.refreshArtistImage(payload.artistId);
      }
      case 'album_cover': {
        const payload = job.data as AlbumCoverJob;
        console.log(`[AlbumCover] Refreshing cover for album: ${payload.albumId}`);
        return await enrichmentService.refreshAlbumCover(payload.albumId);
      }
      case 'audio_analysis': {
        const payload = job.data as AudioAnalysisJob;
        console.log(`[Audio] Analyzing track: ${payload.trackId}`);
        await withAudioSlot(() => audioFeaturesService.extractAndPersist(payload.trackId));
        console.log(`[Audio] Successfully analyzed track: ${payload.trackId}`);
        break;
      }
      case 'audio_analysis_sweep': {
        const payload = job.data as AudioAnalysisSweepJob;
        const enabledResult = await pgPool.query<{ value: string }>(
          "SELECT value FROM settings WHERE key = 'enrich_audio_analysis'"
        );
        if (enabledResult.rows[0]?.value !== 'true') {
          console.log('[Audio] Sweep skipped: enrich_audio_analysis is disabled');
          break;
        }

        const requestedLimit = payload.limit ?? AUDIO_ANALYSIS_BATCH_SIZE;
        const limit = Math.min(100, Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : AUDIO_ANALYSIS_BATCH_SIZE));
        const stale = await pgPool.query<{ id: string }>(
          `SELECT t.id
           FROM tracks t
           LEFT JOIN track_audio_features af ON af.track_id = t.id
           WHERE t.state IN ('LIBRARY', 'RECOMMENDED')
             AND (
               af.track_id IS NULL
               OR af.analysis_version < $1
               OR af.source_hash IS DISTINCT FROM t.hash
             )
           ORDER BY COALESCE(af.analyzed_at, to_timestamp(0)), t.id
           LIMIT $2`,
          [AUDIO_ANALYSIS_VERSION, limit]
        );
        for (const track of stale.rows) {
          await queue.add('audio_analysis', { trackId: track.id } satisfies AudioAnalysisJob, {
            jobId: audioAnalysisJobId(track.id),
            ...AUDIO_ANALYSIS_JOB_OPTIONS,
          });
        }
        console.log(`[Audio] Sweep enqueued ${stale.rows.length} track(s)`);
        break;
      }
      case 'integrity_sweep': {
        // Periodic self-healing pass: detect corrupt/missing track metadata,
        // auto-fix via rescan + SQL strip, flag the rest for manual review.
        console.log('[Integrity] Starting integrity sweep');
        const integrityService = new IntegrityService(pgPool, queue);
        const summary = await integrityService.runSweep();
        if (summary.aborted) {
          console.error(`[Integrity] Sweep ABORTED by safety guard: ${summary.abortReason}`);
        } else {
          console.log(
            `[Integrity] Sweep complete: detected=${summary.detected} fixed=${summary.fixed} needsReview=${summary.needsReview}`
          );
        }
        break;
      }
      case 'cleanup_sweep': {
        console.log('[Cleanup] Starting dislike cleanup sweep');
        const cleanupService = new CleanupSweepService(pgPool);
        const result = await cleanupService.runSweep();
        console.log(
          `[Cleanup] Sweep complete: warned=${result.warned} eligible=${result.eligible} ` +
            `deleted=${result.deleted} failed=${result.failed} reaped=${result.reaped}` +
            (result.dryRun ? ' (DRY RUN — MUZICK_ALLOW_HARD_DELETE off, nothing deleted)' : '')
        );
        break;
      }
      case 'vibe_reap': {
        // Stale-session reaper (Invariant B). Transitions ACTIVE batches with no
        // interaction for 24h to RESOLVED. Runs hourly; the SQL is idempotent.
        const reaped = await pgPool.query(
          `UPDATE recommendation_batch
              SET status = 'RESOLVED'
            WHERE status = 'ACTIVE'
              AND last_interaction_at < NOW() - INTERVAL '24 hours'`
        );
        const n = reaped.rowCount ?? 0;
        if (n > 0) console.log(`[VibeReap] Reaped ${n} stale ACTIVE session(s)`);
        break;
      }
      case 'reindex_tracks': {
        // Sync all LIBRARY tracks from Postgres into the Typesense 'tracks'
        // collection. Inlined to avoid duplicating a service module.
        const { Client } = await import('typesense');
        const searchHost = process.env.TYPESENSE_HOST || 'localhost';
        const searchPort = parseInt(process.env.TYPESENSE_PORT || '8108', 10);
        const searchApiKey = process.env.TYPESENSE_API_KEY || '';

        const tsClient = new Client({
          nodes: [{ host: searchHost, port: searchPort, protocol: 'http' }],
          apiKey: searchApiKey,
        });

        // Ensure collection schema exists.
        try {
          await tsClient.collections().create({
            name: 'tracks',
            fields: [
              { name: 'id', type: 'string' },
              { name: 'title', type: 'string' },
              { name: 'artist', type: 'string' },
              { name: 'album', type: 'string' },
              { name: 'duration', type: 'int32' },
              { name: 'play_count', type: 'int32' },
              { name: 'genre', type: 'string[]', facet: true },
              { name: 'source_type', type: 'string' },
            ],
          });
        } catch (err: any) {
          if (!err?.message?.includes('already exists')) throw err;
        }

        const tracksRes = await pgPool.query(
          `SELECT t.id, t.title, t.artist, al.title AS album, t.duration, t.play_count, t.source_type
           FROM tracks t
           LEFT JOIN albums al ON al.id = t.album_id
           WHERE t.state IN ('LIBRARY', 'RECOMMENDED')`
        );
        const tracks = tracksRes.rows;

        const genreMap = new Map<string, string[]>();
        const genreRes = await pgPool.query(
          `SELECT tg.track_id, g.name FROM track_genre tg JOIN genre g ON g.id = tg.genre_id`
        );
        for (const row of genreRes.rows) {
          const arr = genreMap.get(row.track_id) || [];
          arr.push(row.name);
          genreMap.set(row.track_id, arr);
        }

        const collection = tsClient.collections('tracks').documents();
        const batchSize = 100;
        let indexed = 0;

        for (let i = 0; i < tracks.length; i += batchSize) {
          const batch = tracks.slice(i, i + batchSize);
          const documents = batch.map((t: any) => ({
            id: String(t.id),
            title: t.title,
            artist: t.artist,
            album: t.album ?? '',
            duration: Math.round(Number(t.duration)),
            play_count: Number(t.play_count),
            genre: genreMap.get(String(t.id)) || [],
            source_type: t.source_type,
          }));
          await collection.import(documents, { action: 'upsert' });
          indexed += documents.length;
        }

        console.log(`[Reindex] Indexed ${indexed} tracks into Typesense`);
        break;
      }
      case 'reprocess_artists': {
        await reprocessArtists(job.data as ReprocessArtistsJob, {
          pgPool,
          queue,
          enrichmentService,
        });
        break;
      }
      case 'acquire_discovery_candidate': {
        const payload = job.data as AcquisitionJob;
        const result = await acquisitionService.acquire(payload.candidateId);
        console.log(`[Acquisition] Candidate ${payload.candidateId}: ${result.status}${'reason' in result ? ` (${result.reason})` : ''}`);
        if (result.status === 'failed') throw new Error(result.reason);
        return result;
      }
      case 'discover_new_releases': {
        const payload = job.data as DiscoverySourceJob;
        const result = await discoverySources.discoverNewReleases(payload.seeds, payload.perSeed);
        console.log(`[Discovery] New releases considered=${result.considered} inserted=${result.inserted}`);
        return result;
      }
      case 'discover_recommendations': {
        const payload = job.data as DiscoverySourceJob;
        const result = await discoverySources.discoverRecommendations(payload.seeds, payload.perSeed);
        console.log(`[Discovery] Recommendations considered=${result.considered} inserted=${result.inserted}`);
        return result;
      }
      case 'probation_sweep': {
        // Keep probation moving without exposing an operator-only HTTP endpoint
        // as the sole lifecycle driver. These conditions mirror DiscoveryService.
        const retained = await pgPool.query(
          `UPDATE tracks t SET probation_status = 'retained', state = 'LIBRARY'
           WHERE t.probation_status = 'probation'
             AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
                    AND e.entity_id = t.id AND e.signal = 'playback_completed') >= 3`
        );
        const retired = await pgPool.query(
          `UPDATE tracks t SET probation_status = 'retired', state = 'HIDDEN'
           WHERE t.probation_status = 'probation'
             AND t.probation_entered_at < NOW() - INTERVAL '7 days'
             AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
                    AND e.entity_id = t.id AND e.signal = 'playback_completed') = 0
             AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
                    AND e.entity_id = t.id AND e.signal = 'skip_quick') >= 3`
        );
        // Per-source outcomes, so which strategy is worth its bandwidth is a
        // fact rather than a hunch. Logged every sweep because the interesting
        // number is the trend, and the counts are a single grouped scan.
        const bySource = await pgPool.query<{ source: string; probation: string; retained: string; retired: string }>(
          `SELECT dc.source,
                  COUNT(*) FILTER (WHERE t.probation_status = 'probation')::text AS probation,
                  COUNT(*) FILTER (WHERE t.probation_status = 'retained')::text  AS retained,
                  COUNT(*) FILTER (WHERE t.probation_status = 'retired')::text   AS retired
             FROM discovery_candidates dc
             JOIN tracks t ON t.id = dc.acquired_track_id
            GROUP BY dc.source
            ORDER BY dc.source`
        );
        const result = {
          retained: retained.rowCount ?? 0,
          retired: retired.rowCount ?? 0,
          bySource: bySource.rows,
        };
        console.log(`[Probation] Sweep retained=${result.retained} retired=${result.retired}`);
        console.log(`[Probation] Lifetime by source: ${JSON.stringify(result.bySource)}`);
        return result;
      }
      default:
        console.log(`Received job of type: ${job.name} with data:`, job.data);
        break;
    }
  }, { connection, concurrency: WORKER_CONCURRENCY });

  worker.on('completed', (job) => {
    console.log(`Job ${job.id} has completed!`);
  });

  worker.on('failed', (job, err) => {
    console.log(`Job ${job?.id} has failed with error: ${err.message}`);
  });

  // Register the repeatable integrity sweep. bullmq's upsertJobScheduler is
  // idempotent: keying on a fixed scheduler id ('integrity-sweep') means worker
  // restarts update the existing schedule in place rather than stacking
  // duplicate repeatable jobs.
  await queue.upsertJobScheduler(
    'integrity-sweep',
    { pattern: INTEGRITY_SWEEP_CRON },
    { name: 'integrity_sweep', data: { reason: 'scheduled' } }
  );
  console.log(`[Integrity] Sweep scheduled with cron: ${INTEGRITY_SWEEP_CRON}`);

  await queue.upsertJobScheduler(
    'cleanup-sweep',
    { pattern: CLEANUP_SWEEP_CRON },
    { name: 'cleanup_sweep', data: { reason: 'scheduled' } }
  );
  console.log(`[Cleanup] Sweep scheduled with cron: ${CLEANUP_SWEEP_CRON}`);

  // Stale vibe-session reaper (Invariant B): hourly sweep that transitions
  // ACTIVE batches idle for 24h to RESOLVED.
  await queue.upsertJobScheduler(
    'vibe-reap',
    { pattern: VIBE_REAP_CRON },
    { name: 'vibe_reap', data: { reason: 'scheduled' } }
  );
  console.log(`[VibeReap] Reaper scheduled with cron: ${VIBE_REAP_CRON}`);

  await queue.upsertJobScheduler(
    'probation-sweep',
    { pattern: PROBATION_SWEEP_CRON },
    { name: 'probation_sweep', data: { reason: 'scheduled' } }
  );
  console.log(`[Probation] Sweep scheduled with cron: ${PROBATION_SWEEP_CRON}`);

  await queue.upsertJobScheduler(
    'discover-new-releases',
    { pattern: NEW_RELEASE_CRON },
    { name: 'discover_new_releases', data: { reason: 'scheduled' } satisfies DiscoverySourceJob }
  );
  console.log(`[Discovery] New-release scan scheduled with cron: ${NEW_RELEASE_CRON}`);

  await queue.upsertJobScheduler(
    'discover-recommendations',
    { pattern: RECOMMENDATION_CRON },
    { name: 'discover_recommendations', data: { reason: 'scheduled' } satisfies DiscoverySourceJob }
  );
  console.log(`[Discovery] Recommendation scan scheduled with cron: ${RECOMMENDATION_CRON}`);

  await queue.upsertJobScheduler(
    'audio-analysis-sweep',
    { pattern: AUDIO_ANALYSIS_SWEEP_CRON },
    { name: 'audio_analysis_sweep', data: { reason: 'scheduled', limit: AUDIO_ANALYSIS_BATCH_SIZE } satisfies AudioAnalysisSweepJob }
  );
  console.log(`[Audio] Bounded analysis sweep scheduled with cron: ${AUDIO_ANALYSIS_SWEEP_CRON}, batch: ${AUDIO_ANALYSIS_BATCH_SIZE}`);

  // Enqueue a startup scan only when the database is empty (fresh volume after
  // deleting data/postgres, or first deploy). On subsequent restarts the library
  // is already populated, so an unconditional scan would be wasteful.
  const startupDir = process.env.MUSIC_DIR || '/music';
  const trackCount = await pgPool.query('SELECT COUNT(*)::int AS n FROM tracks');
  if (trackCount.rows[0].n === 0) {
    await queue.add('scan_library', { directory: startupDir }, {
      removeOnComplete: { age: 86400, count: 100 },
      removeOnFail: { age: 86400, count: 100 },
    });
    console.log(`[Startup] Enqueued library scan (fresh DB): ${startupDir}`);
  } else {
    console.log(`[Startup] Library has ${trackCount.rows[0].n} tracks — skipping startup scan`);
  }

  console.log('Worker is running and listening for jobs...');

  // Graceful shutdown: close the worker, the scheduler queue and the pg client
  // on termination signals. Guarded so a second signal is a no-op.
  let shuttingDown = false;
  const shutdown = async (signal: string) => {
    if (shuttingDown) return;
    shuttingDown = true;
    console.log(`[Shutdown] Received ${signal}, shutting down gracefully...`);
    try {
      await worker.close();
      await queue.close();
      await pgPool.end();
      console.log('[Shutdown] Clean shutdown complete.');
    } catch (err) {
      console.error('[Shutdown] Error during shutdown:', err);
    }
    process.exit(0);
  };

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

initWorker().catch(err => {
  console.error('Failed to initialize worker:', err);
  process.exit(1);
});
